diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1816b09 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: Test +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + branches: [main] + types: + - opened + - reopened + - synchronize + - ready_for_review +jobs: + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup Rust Toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Test + run: cargo test diff --git a/Cargo.lock b/Cargo.lock index bad2979..5941ba7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -519,12 +519,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - [[package]] name = "scroll" version = "0.12.0" @@ -587,15 +581,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -759,7 +753,7 @@ dependencies = [ [[package]] name = "uniffi-bindgen-node" -version = "0.1.0" +version = "0.1.3" dependencies = [ "anyhow", "askama 0.14.0", @@ -768,6 +762,7 @@ dependencies = [ "clap", "heck", "serde", + "serde_json", "textwrap", "toml", "uniffi", @@ -1006,3 +1001,9 @@ name = "wit-bindgen" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zmij" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" diff --git a/Cargo.toml b/Cargo.toml index eeb6de4..7866598 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ cargo_metadata = "0.19.2" # NOTE: keep this version up to date with the version clap = { version = "4.5.51", features = ["derive"] } heck = "0.5.0" serde = "1.0.228" +serde_json = "1.0.149" textwrap = "0.16.2" toml = "0.9.8" uniffi = "0.30.0" diff --git a/src/bindings/generator.rs b/src/bindings/generator.rs index 480031d..efce1f0 100644 --- a/src/bindings/generator.rs +++ b/src/bindings/generator.rs @@ -8,11 +8,12 @@ use anyhow::{Context, Result}; use askama::Template; use heck::ToKebabCase; -use crate::bindings::{filters, utils::{DirnameApi, ImportExtension}}; +use crate::bindings::{filters, utils::{DirnameApi, ImportExtension, LibPath, LibPathSwitchToken}}; pub struct Bindings { pub package_json_contents: String, pub sys_ts_template_contents: String, + pub commonjs_shim_cts_template_contents: String, pub node_ts_file_contents: String, pub index_ts_file_contents: String, } @@ -22,11 +23,12 @@ pub struct Bindings { struct PackageJsonTemplate<'ci> { ci: &'ci ComponentInterface, out_node_version: String, + out_lib_path: LibPath, } impl<'ci> PackageJsonTemplate<'ci> { - pub fn new(ci: &'ci ComponentInterface, out_node_version: &str) -> Self { - Self { ci, out_node_version: out_node_version.into() } + pub fn new(ci: &'ci ComponentInterface, out_node_version: &str, out_lib_path: LibPath) -> Self { + Self { ci, out_node_version: out_node_version.into(), out_lib_path } } } @@ -36,22 +38,46 @@ struct SysTemplate<'ci> { ci: &'ci ComponentInterface, out_dirname_api: DirnameApi, - out_disable_auto_loading_lib: bool, + out_lib_disable_auto_loading: bool, out_verbose_logs: bool, + out_lib_path: LibPath, + commonjs_shim_cjs_main_file_name: String, } impl<'ci> SysTemplate<'ci> { pub fn new( ci: &'ci ComponentInterface, out_dirname_api: DirnameApi, - out_disable_auto_loading_lib: bool, + out_lib_disable_auto_loading: bool, out_verbose_logs: bool, + out_lib_path: LibPath, + commonjs_shim_cjs_main_file_name: &str, ) -> Self { - Self { ci, out_dirname_api, out_disable_auto_loading_lib, out_verbose_logs } + Self { + ci, + out_dirname_api, + out_lib_disable_auto_loading, + out_verbose_logs, + out_lib_path, + commonjs_shim_cjs_main_file_name: commonjs_shim_cjs_main_file_name.into(), + } + } +} + +#[derive(Template)] +#[template(escape = "none", path = "commonjs-shim.cts")] +struct CommonJsShimTemplate { + out_lib_path: LibPath, +} + +impl CommonJsShimTemplate { + pub fn new(out_lib_path: LibPath) -> Self { + Self { out_lib_path } } } + #[derive(Template)] #[template(escape = "none", path = "node.ts")] struct NodeTsTemplate<'ci> { @@ -83,7 +109,7 @@ struct IndexTsTemplate { node_ts_main_file_name: String, sys_ts_main_file_name: String, out_import_extension: ImportExtension, - out_disable_auto_loading_lib: bool, + out_lib_disable_auto_loading: bool, } impl IndexTsTemplate { @@ -91,35 +117,45 @@ impl IndexTsTemplate { node_ts_main_file_name: &str, sys_ts_main_file_name: &str, out_import_extension: ImportExtension, - out_disable_auto_loading_lib: bool, + out_lib_disable_auto_loading: bool, ) -> Self { Self { node_ts_main_file_name: node_ts_main_file_name.to_string(), sys_ts_main_file_name: sys_ts_main_file_name.to_string(), out_import_extension, - out_disable_auto_loading_lib, + out_lib_disable_auto_loading, } } } +/// Options required to pass to [generate_node_bindings] invocations. +#[derive(Debug)] +pub struct GenerateNodeBindingsOptions<'a> { + pub sys_ts_main_file_name: &'a str, + pub node_ts_main_file_name: &'a str, + pub commonjs_shim_cts_main_file_name: &'a str, + pub out_dirname_api: DirnameApi, + pub out_lib_disable_auto_loading: bool, + pub out_import_extension: ImportExtension, + pub out_node_version: &'a str, + pub out_verbose_logs: bool, + pub out_lib_path: LibPath, +} + pub fn generate_node_bindings( ci: &ComponentInterface, - sys_ts_main_file_name: &str, - node_ts_main_file_name: &str, - out_dirname_api: DirnameApi, - out_disable_auto_loading_lib: bool, - out_import_extension: ImportExtension, - out_node_version: &str, - out_verbose_logs: bool, + options: GenerateNodeBindingsOptions<'_>, ) -> Result { - let package_json_contents = PackageJsonTemplate::new(ci, out_node_version).render().context("failed to render package.json template")?; - let sys_template_contents = SysTemplate::new(ci, out_dirname_api, out_disable_auto_loading_lib, out_verbose_logs).render().context("failed to render sys.ts template")?; - let node_ts_file_contents = NodeTsTemplate::new(ci, sys_ts_main_file_name, out_import_extension.clone(), out_verbose_logs).render().context("failed to render node.ts template")?; - let index_ts_file_contents = IndexTsTemplate::new(node_ts_main_file_name, sys_ts_main_file_name, out_import_extension, out_disable_auto_loading_lib).render().context("failed to render index.ts template")?; + let package_json_contents = PackageJsonTemplate::new(ci, options.out_node_version, options.out_lib_path.clone()).render().context("failed to render package.json template")?; + let sys_ts_template_contents = SysTemplate::new(ci, options.out_dirname_api, options.out_lib_disable_auto_loading, options.out_verbose_logs, options.out_lib_path.clone(), options.commonjs_shim_cts_main_file_name).render().context("failed to render sys.ts template")?; + let commonjs_shim_cts_template_contents = CommonJsShimTemplate::new(options.out_lib_path.clone()).render().context("failed to render commonjs_shim.cjs template")?; + let node_ts_file_contents = NodeTsTemplate::new(ci, options.sys_ts_main_file_name, options.out_import_extension.clone(), options.out_verbose_logs).render().context("failed to render node.ts template")?; + let index_ts_file_contents = IndexTsTemplate::new(options.node_ts_main_file_name, options.sys_ts_main_file_name, options.out_import_extension, options.out_lib_disable_auto_loading).render().context("failed to render index.ts template")?; Ok(Bindings { package_json_contents, - sys_ts_template_contents: sys_template_contents, + sys_ts_template_contents, + commonjs_shim_cts_template_contents, node_ts_file_contents, index_ts_file_contents, }) diff --git a/src/bindings/mod.rs b/src/bindings/mod.rs index 183d7f7..843bb69 100644 --- a/src/bindings/mod.rs +++ b/src/bindings/mod.rs @@ -11,30 +11,33 @@ mod generator; mod filters; pub mod utils; -use crate::{bindings::generator::{generate_node_bindings, Bindings}, utils::write_with_dirs}; +use crate::{bindings::generator::{generate_node_bindings, Bindings, GenerateNodeBindingsOptions}, utils::write_with_dirs}; pub struct NodeBindingGenerator { out_dirname_api: utils::DirnameApi, - out_disable_auto_loading_lib: bool, + out_lib_disable_auto_loading: bool, out_import_extension: utils::ImportExtension, out_node_version: String, out_verbose_logs: bool, + out_lib_path: utils::LibPath, } impl NodeBindingGenerator { pub fn new( out_dirname_api: utils::DirnameApi, - out_disable_auto_loading_lib: bool, + out_lib_disable_auto_loading: bool, out_import_extension: utils::ImportExtension, out_node_version: &str, out_verbose_logs: bool, + out_lib_path: utils::LibPath, ) -> Self { Self { out_dirname_api, - out_disable_auto_loading_lib, + out_lib_disable_auto_loading, out_import_extension, out_node_version: out_node_version.into(), out_verbose_logs, + out_lib_path, } } } @@ -70,21 +73,27 @@ impl BindingGenerator for NodeBindingGenerator { for uniffi_bindgen::Component { ci, config: _, .. } in components { let sys_ts_main_file_name = format!("{}-sys", ci.namespace().to_kebab_case()); let node_ts_main_file_name = format!("{}-node", ci.namespace().to_kebab_case()); + let commonjs_shim_cts_main_file_name = format!("{}-commonjs-shim", ci.namespace().to_kebab_case()); let Bindings { package_json_contents, sys_ts_template_contents, + commonjs_shim_cts_template_contents, node_ts_file_contents, index_ts_file_contents, } = generate_node_bindings( &ci, - sys_ts_main_file_name.as_str(), - node_ts_main_file_name.as_str(), - self.out_dirname_api.clone(), - self.out_disable_auto_loading_lib, - self.out_import_extension.clone(), - self.out_node_version.as_str(), - self.out_verbose_logs, + GenerateNodeBindingsOptions { + sys_ts_main_file_name: sys_ts_main_file_name.as_str(), + node_ts_main_file_name: node_ts_main_file_name.as_str(), + commonjs_shim_cts_main_file_name: commonjs_shim_cts_main_file_name.as_str(), + out_dirname_api: self.out_dirname_api.clone(), + out_lib_disable_auto_loading: self.out_lib_disable_auto_loading, + out_import_extension: self.out_import_extension.clone(), + out_node_version: self.out_node_version.as_str(), + out_verbose_logs: self.out_verbose_logs, + out_lib_path: self.out_lib_path.clone(), + } )?; let package_json_path = settings.out_dir.join("package.json"); @@ -96,6 +105,11 @@ impl BindingGenerator for NodeBindingGenerator { let sys_template_path = settings.out_dir.join(format!("{sys_ts_main_file_name}.ts")); write_with_dirs(&sys_template_path, sys_ts_template_contents)?; + if !commonjs_shim_cts_template_contents.is_empty() { + let commonjs_shim_template_path = settings.out_dir.join(format!("{commonjs_shim_cts_main_file_name}.cts")); + write_with_dirs(&commonjs_shim_template_path, commonjs_shim_cts_template_contents)?; + } + let index_template_path = settings.out_dir.join("index.ts"); write_with_dirs(&index_template_path, index_ts_file_contents)?; } diff --git a/src/bindings/utils.rs b/src/bindings/utils.rs index e973d52..3bbd51f 100644 --- a/src/bindings/utils.rs +++ b/src/bindings/utils.rs @@ -2,6 +2,11 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ +use std::collections::{HashMap, HashSet}; + +use camino::Utf8PathBuf; +use serde::Deserialize; + #[derive(Debug, PartialEq, Eq, Clone)] pub enum DirnameApi { Dirname, @@ -15,3 +20,363 @@ pub enum ImportExtension { Js, } +#[derive(Debug, Clone)] +pub enum LibPath { + Omitted, + Literal(Utf8PathBuf), + Modules(LibPathModules), +} + +impl LibPath { + pub fn from_raw( + out_lib_path_literal: Option, + out_lib_path_module: Option>, + ) -> Self { + if let Some(value) = out_lib_path_literal { + return Self::Literal(value); + + } else if let Some(mods) = out_lib_path_module { + Self::Modules(LibPathModules(mods.into_iter().map(|module| { + serde_json::from_str(module.as_str()).unwrap_or( + SerializedLibPathModule::from(module) + ).into() + }).collect())) + + } else { + Self::Omitted + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct SerializedLibPathModule { + pub module: String, + pub version: Option, + pub platform: Option, + pub arch: Option, +} + +impl From for SerializedLibPathModule { + fn from(module: String) -> Self { + Self { module, version: None, platform: None, arch: None } + } +} + +impl From for LibPathModule { + fn from(value: SerializedLibPathModule) -> Self { + let mut module = LibPathModule::new(value.module.as_str()); + + if let Some(version) = value.version { + module = module.with_optional_dependency_version(version); + }; + if let Some(platform) = value.platform { + module = module.with_filter("process.platform", platform); + }; + if let Some(arch) = value.arch { + module = module.with_filter("process.arch", arch); + }; + + module + } +} + +/// A struct representing a node.js js module containing a native dll / dylib / so. +#[derive(Debug, Clone)] +pub struct LibPathModule { + pub require_path: String, + + /// The `optionalDependencies` version of the given package. If unset, don't add the module to + /// `optionalDependencies` in the generated package.json. + pub optional_dependency_version: Option, + + /// A set of abstract filters used to determine which systems this given module should be + /// loaded on. Filters are arbitrary but examples could be os, cpu architecture, etc. + pub filters: HashMap<&'static str, String>, +} + +impl LibPathModule { + pub fn new(require_path: &str) -> Self { + Self { + require_path: require_path.into(), + filters: Default::default(), + optional_dependency_version: None, + } + } + pub fn with_filter(mut self, filter_key: &'static str, filter_value: String) -> Self { + self.filters.insert(filter_key, filter_value); + self + } + pub fn with_optional_dependency_version(mut self, optional_dependency_version: String) -> Self { + self.optional_dependency_version = Some(optional_dependency_version); + self + } +} + +#[derive(Debug, Clone)] +pub struct LibPathModules(Vec); + +#[derive(Debug, Clone, PartialEq)] +pub enum LibPathSwitchToken { + Switch(&'static str), + Case(String), + EndCase, + EndSwitch(&'static str), + Value(T), +} + +impl LibPathModules { + pub fn as_switch_tokens_by( + &self, + dimensions: Vec<&'static str>, + ) -> Vec> { + if self.0.is_empty() { + return vec![]; + }; + + let Some((first_dimension, rest_dimensions)) = dimensions.split_first() else { + return self + .0 + .iter() + .map(|module| LibPathSwitchToken::Value(module.require_path.clone())) + .collect(); + }; + + let mut grouped_modules = HashMap::new(); + for module_entry in self.0.iter() { + let Some(dimension_value) = module_entry.filters.get(*first_dimension) else { + continue; + }; + + grouped_modules + .entry(dimension_value.clone()) + .or_insert(vec![]) + .push(module_entry); + } + + let mut tokens = vec![]; + + tokens.push(LibPathSwitchToken::Switch(*first_dimension)); + + // NOTE: sort the cases in alhpabetical order so that the tests below can assert against + // the token list detemrinistically. + let mut sorted_grouped_modules = grouped_modules.into_iter().collect::>(); + sorted_grouped_modules.sort_by(|(a_key, _), (b_key, _)| a_key.cmp(b_key)); + + for (first_dimension_value, module_entries) in sorted_grouped_modules { + tokens.push(LibPathSwitchToken::Case(first_dimension_value)); + + let module_entries_cloned = module_entries + .iter() + .map(|entry| { + let mut cloned = (*entry).clone(); + cloned.filters.remove(first_dimension); + cloned + }) + .collect(); + + let values = Self(module_entries_cloned).as_switch_tokens_by(rest_dimensions.to_vec()); + tokens.extend(values); + tokens.push(LibPathSwitchToken::EndCase); + } + tokens.push(LibPathSwitchToken::EndSwitch(*first_dimension)); + + // Finish the tokens list with any entries that don't have associated filters + tokens.extend( + self.0 + .iter() + .filter(|module_entry| module_entry.filters.is_empty()) + .map(|module_entry| LibPathSwitchToken::Value(module_entry.require_path.clone())), + ); + + tokens + } + + pub fn as_switch_tokens(&self) -> Vec> { + let keys = self + .0 + .iter() + .flat_map(|module| module.filters.keys()) + .map(|key| *key) + .collect::>(); + self.as_switch_tokens_by(keys.into_iter().collect()) + } + + pub fn optional_dependencies(&self) -> HashMap { + self.0 + .iter() + .filter_map(|m| { + m.optional_dependency_version + .clone() + .map(|d| (m.require_path.clone(), d)) + }) + .collect::>() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_happy_path() { + let result = LibPathModules(vec![ + LibPathModule::new("foo") + .with_filter("arch", "x86".into()) + .with_filter("platform", "win".into()), + LibPathModule::new("bar") + .with_filter("arch", "x86".into()) + .with_filter("platform", "mac".into()), + LibPathModule::new("baz") + .with_filter("arch", "aarch64".into()) + .with_filter("platform", "win".into()), + ]) + .as_switch_tokens_by(vec!["arch", "platform"]); + + assert_eq!( + result, + vec![ + LibPathSwitchToken::Switch("arch"), + LibPathSwitchToken::Case("aarch64".into()), + LibPathSwitchToken::Switch("platform"), + LibPathSwitchToken::Case("win".into()), + LibPathSwitchToken::Value("baz".into()), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::EndSwitch("platform"), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::Case("x86".into()), + LibPathSwitchToken::Switch("platform"), + LibPathSwitchToken::Case("mac".into()), + LibPathSwitchToken::Value("bar".into()), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::Case("win".into()), + LibPathSwitchToken::Value("foo".into()), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::EndSwitch("platform"), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::EndSwitch("arch"), + ] + ); + } + + #[test] + fn test_filter_values_go_right_after_each_other() { + let result = LibPathModules(vec![ + LibPathModule::new("foo") + .with_filter("arch", "x86".into()) + .with_filter("platform", "win".into()), + // NOTE: bar and baz have the same filters... + LibPathModule::new("bar") + .with_filter("arch", "x86".into()) + .with_filter("platform", "mac".into()), + LibPathModule::new("baz") + .with_filter("arch", "x86".into()) + .with_filter("platform", "mac".into()), + LibPathModule::new("quux") + .with_filter("arch", "aarch64".into()) + .with_filter("platform", "win".into()), + ]) + .as_switch_tokens_by(vec!["arch", "platform"]); + + assert_eq!( + result, + vec![ + LibPathSwitchToken::Switch("arch"), + LibPathSwitchToken::Case("aarch64".into()), + LibPathSwitchToken::Switch("platform"), + LibPathSwitchToken::Case("win".into()), + LibPathSwitchToken::Value("quux".into()), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::EndSwitch("platform"), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::Case("x86".into()), + LibPathSwitchToken::Switch("platform"), + LibPathSwitchToken::Case("mac".into()), + LibPathSwitchToken::Value("bar".into()), // ... so bar goes here + LibPathSwitchToken::Value("baz".into()), // and baz goes right afterwards (sorted alphabetically) + LibPathSwitchToken::EndCase, + LibPathSwitchToken::Case("win".into()), + LibPathSwitchToken::Value("foo".into()), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::EndSwitch("platform"), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::EndSwitch("arch"), + ] + ); + } + + #[test] + fn test_cases_without_filters_go_last() { + let result = LibPathModules(vec![ + LibPathModule::new("foo") + .with_filter("arch", "x86".into()) + .with_filter("platform", "win".into()), + LibPathModule::new("bar") + .with_filter("arch", "x86".into()) + .with_filter("platform", "mac".into()), + LibPathModule::new("baz"), // NOTE: baz has no filters... + ]) + .as_switch_tokens_by(vec!["arch", "platform"]); + + assert_eq!( + result, + vec![ + LibPathSwitchToken::Switch("arch"), + LibPathSwitchToken::Case("x86".into()), + LibPathSwitchToken::Switch("platform"), + LibPathSwitchToken::Case("mac".into()), + LibPathSwitchToken::Value("bar".into()), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::Case("win".into()), + LibPathSwitchToken::Value("foo".into()), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::EndSwitch("platform"), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::EndSwitch("arch"), + LibPathSwitchToken::Value("baz".into()), // ... so baz goes last. + ] + ); + } + + #[test] + fn test_no_filters() { + let result = LibPathModules(vec![]).as_switch_tokens_by(vec!["arch", "platform"]); + assert_eq!(result, vec![]); + } + + #[test] + fn test_not_every_entry_has_every_filter() { + let result = LibPathModules(vec![ + LibPathModule::new("foo").with_filter("arch", "x86".into()), + LibPathModule::new("bar") + .with_filter("arch", "x86".into()) + .with_filter("platform", "mac".into()), + LibPathModule::new("baz") + .with_filter("arch", "aarch64".into()) + .with_filter("platform", "win".into()), + ]) + .as_switch_tokens_by(vec!["arch", "platform"]); + + assert_eq!( + result, + vec![ + LibPathSwitchToken::Switch("arch"), + LibPathSwitchToken::Case("aarch64".into()), + LibPathSwitchToken::Switch("platform"), + LibPathSwitchToken::Case("win".into()), + LibPathSwitchToken::Value("baz".into()), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::EndSwitch("platform"), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::Case("x86".into()), + LibPathSwitchToken::Switch("platform"), + LibPathSwitchToken::Case("mac".into()), + LibPathSwitchToken::Value("bar".into()), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::EndSwitch("platform"), + LibPathSwitchToken::Value("foo".into()), + LibPathSwitchToken::EndCase, + LibPathSwitchToken::EndSwitch("arch"), + ] + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3f03ade..c472626 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,136 +2,21 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ -use anyhow::{Context, Result}; -use camino::Utf8PathBuf; +use anyhow::Result; use clap::Parser; mod bindings; mod utils; - -#[derive(Debug, Clone, Default, clap::ValueEnum)] -enum OutputDirnameApi { - #[default] - Dirname, - ImportMetaUrl, -} - -impl Into for OutputDirnameApi { - fn into(self) -> bindings::utils::DirnameApi { - match self { - OutputDirnameApi::ImportMetaUrl => bindings::utils::DirnameApi::ImportMetaUrl, - OutputDirnameApi::Dirname => bindings::utils::DirnameApi::Dirname, - } - } -} - -#[derive(Debug, Clone, Default, clap::ValueEnum)] -enum OutputImportExtension { - #[default] - None, - Ts, - Js, -} - -impl Into for OutputImportExtension { - fn into(self) -> bindings::utils::ImportExtension { - match self { - OutputImportExtension::None => bindings::utils::ImportExtension::None, - OutputImportExtension::Ts => bindings::utils::ImportExtension::Ts, - OutputImportExtension::Js => bindings::utils::ImportExtension::Js, - } - } -} +mod subcommands; /// UniFFI binding generator for Node.js #[derive(Parser, Debug)] #[command(version, about, long_about = None)] -pub struct Args { - /// Path to the compiled library (.so, .dylib, or .dll). - lib_source: Utf8PathBuf, - - /// Output directory. - #[arg(short, long, default_value = "./output")] - out_dir: Utf8PathBuf, - - /// Name of the crate. - #[arg(long)] - crate_name: String, - - /// The set of buildin apis which should be used to get the current - /// directory - `__dirname` or `import.meta.url`. - #[arg(long, value_enum, default_value_t=OutputDirnameApi::default())] - out_dirname_api: OutputDirnameApi, - - /// If specified, the dylib/so/dll native dependency won't be automatically loaded - /// when the bindgen is imported. If this flag is set, explicit `uniffiLoad` / `uniffiUnload` - /// will be exported from the generated package which must be called before any uniffi calls - /// are made. - /// - /// Use this if you want to only load a bindgen sometimes (ie, it is an optional dependency). - #[arg(long, action)] - out_disable_auto_load_lib: bool, - - /// Changes the extension used in `import`s within the final generated output. This exists - /// because depending on packaging / tsc configuration, the import path extensions may be - /// expected to end in different extensions. For example, tsc often requires .js extensions - /// on .ts files it imports, etc. - #[arg(long, action, value_enum, default_value_t=OutputImportExtension::default())] - out_import_extension: OutputImportExtension, - - /// Specifies the version (in semver) of node that the typescript bindings will depend on in - /// the built output. By default, this is "^18". - #[arg(long, default_value = "^18")] - out_node_version: String, - - /// If passed, adds verbose logging to the bindgen output, which is helpful for debugging - /// issues in the bindgne itself. - #[arg(long, action)] - out_verbose_logs: bool, - - /// Config file override. - #[arg(short, long)] - config_override: Option, +pub struct RootArgs { + #[command(subcommand)] + command: subcommands::Subcommands, } -pub fn run(args: Args) -> Result<()> { - let config_supplier = { - use uniffi_bindgen::cargo_metadata::CrateConfigSupplier; - let cmd = ::cargo_metadata::MetadataCommand::new(); - let metadata = cmd.exec().context("error running cargo metadata")?; - CrateConfigSupplier::from(metadata) - }; - let node_binding_generator = bindings::NodeBindingGenerator::new( - args.out_dirname_api.into(), - args.out_disable_auto_load_lib, - args.out_import_extension.into(), - args.out_node_version.as_str(), - args.out_verbose_logs, - ); - - uniffi_bindgen::library_mode::generate_bindings( - &args.lib_source, - args.crate_name.into(), - &node_binding_generator, - &config_supplier, - args.config_override.as_deref(), - &args.out_dir, - false, - ) - .context("Failed to generate node bindings in library mode")?; - - // To read from udl file, do something like the below instead: - // uniffi_bindgen::generate_external_bindings( - // &CppBindingGenerator { - // scaffolding_mode: args.scaffolding_mode, - // }, - // args.source, - // args.config.as_deref(), - // args.out_dir, - // args.lib_file, - // args.crate_name.as_deref(), - // false, - // ) - - Ok(()) +pub fn run(args: RootArgs) -> Result<()> { + subcommands::run(args.command) } diff --git a/src/main.rs b/src/main.rs index f609deb..5482a67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,9 @@ use anyhow::Result; use clap::Parser; -use uniffi_bindgen_node::{Args, run}; +use uniffi_bindgen_node::{RootArgs, run}; fn main() -> Result<()> { - let args = Args::parse(); + let args = RootArgs::parse(); run(args) } diff --git a/src/subcommands/generate.rs b/src/subcommands/generate.rs new file mode 100644 index 0000000..ad1177c --- /dev/null +++ b/src/subcommands/generate.rs @@ -0,0 +1,161 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +use camino::Utf8PathBuf; +use clap::Args; +use anyhow::{Context, Result}; + +use crate::bindings::{self, utils}; + +#[derive(Debug, Clone, Default, clap::ValueEnum)] +enum OutputDirnameApi { + #[default] + Dirname, + ImportMetaUrl, +} + +impl Into for OutputDirnameApi { + fn into(self) -> bindings::utils::DirnameApi { + match self { + OutputDirnameApi::ImportMetaUrl => bindings::utils::DirnameApi::ImportMetaUrl, + OutputDirnameApi::Dirname => bindings::utils::DirnameApi::Dirname, + } + } +} + +#[derive(Debug, Clone, Default, clap::ValueEnum)] +enum OutputImportExtension { + #[default] + None, + Ts, + Js, +} + +impl Into for OutputImportExtension { + fn into(self) -> bindings::utils::ImportExtension { + match self { + OutputImportExtension::None => bindings::utils::ImportExtension::None, + OutputImportExtension::Ts => bindings::utils::ImportExtension::Ts, + OutputImportExtension::Js => bindings::utils::ImportExtension::Js, + } + } +} + +#[derive(Args, Debug)] +pub struct GenerateSubcommandArgs { + /// Path to the compiled library (.so, .dylib, or .dll). + lib_source: Utf8PathBuf, + + /// Output directory. + #[arg(short, long, default_value = "./output")] + out_dir: Utf8PathBuf, + + /// Name of the crate. + #[arg(long)] + crate_name: String, + + /// The set of builtin apis which should be used to get the current + /// directory - `__dirname` or `import.meta.url`. + #[arg(long, value_enum, default_value_t=OutputDirnameApi::default())] + out_dirname_api: OutputDirnameApi, + + /// Changes the extension used in `import`s within the final generated output. This exists + /// because depending on packaging / tsc configuration, the import path extensions may be + /// expected to end in different extensions. For example, tsc often requires .js extensions + /// on .ts files it imports, etc. + #[arg(long, action, value_enum, default_value_t=OutputImportExtension::default())] + out_import_extension: OutputImportExtension, + + /// Specifies the version (in semver) of node that the typescript bindings will depend on in + /// the built output. By default, this is "^18". + #[arg(long, default_value = "^18")] + out_node_version: String, + + /// If specified, the dylib/so/dll native dependency won't be automatically loaded + /// when the bindgen is imported. If this flag is set, explicit `uniffiLoad` / `uniffiUnload` + /// will be exported from the generated package which must be called before any uniffi calls + /// are made. + /// + /// Use this if you want to only load a bindgen sometimes (ie, it is an optional dependency). + #[arg(long, action)] + out_lib_disable_auto_load: bool, + + /// The relative path to the built lib from the root of the package. + /// By default, this is assumed to be `./`. + #[arg(long, default_value=None, conflicts_with="out_lib_path_module")] + out_lib_path_literal: Option, + + /// The import path to a typescript module that exports a + /// function. This function, when called, should return an object containing a path key mapping + /// to an absolute path to the built lib. + /// + /// For example, the below would be a compliant module: + /// > export default () => ({ path: "/path/to/my/built.dylib" }); + /// + /// This parameter can be included multiple times, and if so, the first module that can be + /// successfully imported will be queried to get the lib path. This can be used when building + /// a package intended to be published to production with a series of `optionalDependencies`, + /// each associated with a given os/arch to bundle native dependencies into a published + /// package. ie, `--out-lib-path-module @my/package --out-lib-path-module ./path/to/my/fallback.ts` + /// + /// This parameter can also be set to a json object which allows for more complex scenarios + /// where one package will be only attempted if a given platform / arch match. ie, + /// `--out-lib-path-module '{"module": "@my/package", "version": "0.0.1", "platform": "darwin", "arch": "x86"}' --out-lib-path-module ./path/to/my/fallback.ts` + /// + /// By default, this is is disabled in lieu of `out-lib-path-literal`. + #[arg(long, value_parser, default_value=None, conflicts_with="out_lib_path_literal")] + out_lib_path_module: Option>, + + /// If passed, adds verbose logging to the bindgen output, which is helpful for debugging + /// issues in the bindgne itself. + #[arg(long, action)] + out_verbose_logs: bool, + + /// Config file override. + #[arg(short, long)] + config_override: Option, +} + +pub fn run(args: GenerateSubcommandArgs) -> Result<()> { + let config_supplier = { + use uniffi_bindgen::cargo_metadata::CrateConfigSupplier; + let cmd = ::cargo_metadata::MetadataCommand::new(); + let metadata = cmd.exec().context("error running cargo metadata")?; + CrateConfigSupplier::from(metadata) + }; + let node_binding_generator = bindings::NodeBindingGenerator::new( + args.out_dirname_api.into(), + args.out_lib_disable_auto_load, + args.out_import_extension.into(), + args.out_node_version.as_str(), + args.out_verbose_logs, + utils::LibPath::from_raw(args.out_lib_path_literal, args.out_lib_path_module), + ); + + uniffi_bindgen::library_mode::generate_bindings( + &args.lib_source, + args.crate_name.into(), + &node_binding_generator, + &config_supplier, + args.config_override.as_deref(), + &args.out_dir, + false, + ) + .context("Failed to generate node bindings in library mode")?; + + // To read from udl file, do something like the below instead: + // uniffi_bindgen::generate_external_bindings( + // &CppBindingGenerator { + // scaffolding_mode: args.scaffolding_mode, + // }, + // args.source, + // args.config.as_deref(), + // args.out_dir, + // args.lib_file, + // args.crate_name.as_deref(), + // false, + // ) + + Ok(()) +} diff --git a/src/subcommands/mod.rs b/src/subcommands/mod.rs new file mode 100644 index 0000000..23c0c06 --- /dev/null +++ b/src/subcommands/mod.rs @@ -0,0 +1,41 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +use clap::Subcommand; +use anyhow::Result; + +pub mod generate; +pub mod publishing_scaffold_native_package; + +#[derive(Subcommand, Debug)] +pub enum Subcommands { + /// Generates node.js bindings for a given set of built uniffi bindings. + /// This is probably the subcommand you want if you are just getting started. + #[command(verbatim_doc_comment)] + Generate(generate::GenerateSubcommandArgs), + + /// Generates a template for a npm package that encapsulates a built dll / dylib / dll. + /// + /// The module has a default export of a function, which when called returns an object with two keys: + /// - "triple" mapping to the built rust triple the package represents + /// - "path" containing an absolute path to the dll / dylib / so that is included in the package. + /// Typescript definitions are also included. ie: + /// + /// > require('./example-native-package').default() + /// { + /// path: '/path/to/example-native-package/src/libplugins_ai_coustics_uniffi.dylib', + /// triple: 'aarch64-apple-darwin' + /// } + /// + /// Only intended for use when publishing package to npm for distribution. + #[command(verbatim_doc_comment)] + PublishingScaffoldNativePackage(publishing_scaffold_native_package::PublishingScaffoldNativePackageSubcommandArgs), +} + +pub fn run(command: Subcommands) -> Result<()> { + match command { + Subcommands::Generate(args) => generate::run(args), + Subcommands::PublishingScaffoldNativePackage(args) => publishing_scaffold_native_package::run(args), + } +} diff --git a/src/subcommands/publishing_scaffold_native_package.rs b/src/subcommands/publishing_scaffold_native_package.rs new file mode 100644 index 0000000..92aedc2 --- /dev/null +++ b/src/subcommands/publishing_scaffold_native_package.rs @@ -0,0 +1,108 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +use std::fs; +use camino::Utf8PathBuf; +use clap::Args; +use anyhow::{Result, Context}; +use serde_json::json; + +/// FOO +#[derive(Args, Debug)] +pub struct PublishingScaffoldNativePackageSubcommandArgs { + /// Path to the compiled library (.so, .dylib, or .dll). + lib_source: Utf8PathBuf, + + /// Rust triple representing the platform that `lib-source` was compiled under. + lib_triple: Utf8PathBuf, + + /// The value to set the "name" field of the generated package.json + #[arg(long)] + package_name: String, + + /// The value to set the "version" field of the generated package.json + #[arg(long, default_value = "0.0.0")] + package_version: String, + + #[arg(long, default_value = None)] + package_os: Option, + + #[arg(long, default_value = None)] + package_cpu: Option, + + /// Output directory to write the native package into. + #[arg(short, long, default_value = "./scaffolded-native-package")] + out_dir: Utf8PathBuf, +} + + + +pub fn run(args: PublishingScaffoldNativePackageSubcommandArgs) -> Result<()> { + let lib_source_filename = args.lib_source.file_name().context("Cannot get filename from --lib-source")?; + + fs::create_dir_all(args.out_dir.clone()).context("Error creating native package root directory")?; + fs::create_dir_all(args.out_dir.clone().join("src")).context("Error creating native package src directory")?; + + fs::copy( + args.lib_source.clone(), + args.out_dir.clone().join("src").join(lib_source_filename), + ).context(format!("Error copying {lib_source_filename} into package"))?; + + let package_json = json!({ + "name": args.package_name, + "version": args.package_version, + "os": if let Some(os) = args.package_os { vec![os] } else { vec![] }, + "cpu": if let Some(cpu) = args.package_cpu { vec![cpu] } else { vec![] }, + + "type": "module", + "main": "./src/index.mjs", + "types": "./src/index.d.mts", + "exports": { + ".": { + "import": { + "types": "./src/index.d.mts", + "default": "./src/index.mjs" + }, + "require": { + "types": "./src/index.d.cts", + "default": "./src/index.cjs" + }, + } + }, + "files": ["src"], + "engines": { "node": ">= 18" }, + }); + fs::write( + args.out_dir.clone().join("package.json"), + serde_json::to_string_pretty(&package_json)?, + ).context("Error writing package.json")?; + + for (filename, contents) in [ + ("index.cjs", format!( + r#"module.exports.default = () => ({{ triple: "{}", path: require("path").join(__dirname, "{}") }});"#, + args.lib_triple, + lib_source_filename, + )), + ("index.mjs", format!( + r#"import {{ join, dirname }} from "path"; import {{ fileURLToPath }} from "url"; export default () => ({{ triple: "{}", path: join(dirname(fileURLToPath(import.meta.url)), "{}") }});"#, + args.lib_triple, + lib_source_filename, + )), + ("index.d.mts", format!( + r#"declare function dlibFn(): {{ triple: "{}", path: string }}; export default dlibFn;"#, + args.lib_triple, + )), + ("index.d.cts", format!( + r#"declare function dlibFn(): {{ triple: "{}", path: string }}; export = dlibFn;"#, + args.lib_triple, + )), + ] { + fs::write( + args.out_dir.clone().join("src").join(filename), + contents, + ).context(format!("Error writing {filename}"))?; + } + + Ok(()) +} diff --git a/templates/commonjs-shim.cts b/templates/commonjs-shim.cts new file mode 100644 index 0000000..dd7673a --- /dev/null +++ b/templates/commonjs-shim.cts @@ -0,0 +1,46 @@ +{%- match out_lib_path -%} + {%- when LibPath::Modules(mods) -%} + export type LibPathResult = { + triple: string; + path: string; + }; + + // This function exists so calls can be made to require in a common js context and + // the results bridged back into the main esm context + module.exports.getLibPathModule = function getLibPathModule(): LibPathResult { + let libPathModule; + let libPathModuleLastResolutionError: Error | null = null; + let libPathModuleLoadAttemptStack: Array = []; + + {%- for switch_token in mods.as_switch_tokens() -%} + {% match switch_token -%} + {% when LibPathSwitchToken::Switch(value) -%} + switch ({{ value }}) { + {% when LibPathSwitchToken::Case(value) -%} + case "{{value}}": + {% when LibPathSwitchToken::EndCase -%} + break; + {% when LibPathSwitchToken::EndSwitch(_value) -%} + } + {% when LibPathSwitchToken::Value(value) -%} + if (!libPathModule) { + try { + libPathModule = require("{{ value }}"); + } catch (e) { + libPathModuleLastResolutionError = e; + libPathModuleLoadAttemptStack.push("{{ value }}"); + } + } + {%- endmatch -%} + {%- endfor -%} + + if (!libPathModule) { + throw new Error(`Failed to load a native binding library! Attempted loading from the following modules in order: ${libPathModuleLoadAttemptStack.join(", ")}. The error message from the final error is ${libPathModuleLastResolutionError}`); + } + + return libPathModule.default(); + } + {%- else -%} + {# Don't render anything otherwise, in the rust code if this file is empty it is not written to disk. #} +{%- endmatch -%} + diff --git a/templates/index.ts b/templates/index.ts index 23b2f4b..cd35d4a 100644 --- a/templates/index.ts +++ b/templates/index.ts @@ -2,6 +2,6 @@ export * from './{%- call ts::import_file_path(node_ts_main_file_name) -%}'; -{% if out_disable_auto_loading_lib %} +{% if out_lib_disable_auto_loading %} export { uniffiLoad, uniffiUnload } from './{%- call ts::import_file_path(sys_ts_main_file_name) -%}'; {% endif %} diff --git a/templates/package.json b/templates/package.json index 4bde8af..109c146 100644 --- a/templates/package.json +++ b/templates/package.json @@ -9,6 +9,14 @@ "ref-napi": "^3.0.3", "uniffi-bindgen-react-native": "^0.29.3-1" }, + {% if let LibPath::Modules(mods) = out_lib_path -%} + "optionalDependencies": { + {% for (require_path, version) in mods.optional_dependencies() -%} + "{{require_path}}": "{{version}}" + {%- if !loop.last %}, {% endif %} + {%- endfor %} + }, + {%- endif %} "devDependencies": { "@types/node": "{{out_node_version}}", "@types/ref-napi": "^3.0.12" diff --git a/templates/sys.ts b/templates/sys.ts index 6090750..fd71104 100644 --- a/templates/sys.ts +++ b/templates/sys.ts @@ -23,6 +23,10 @@ import { uniffiCreateFfiConverterString, UniffiError, } from 'uniffi-bindgen-react-native'; +{% if let LibPath::Modules(_) = out_lib_path %} +import { getLibPathModule } from './{{ commonjs_shim_cjs_main_file_name }}.cts'; +{% endif %} + const CALL_SUCCESS = 0, CALL_ERROR = 1, CALL_UNEXPECTED_ERROR = 2, CALL_CANCELLED = 3; @@ -30,7 +34,7 @@ const CALL_SUCCESS = 0, CALL_ERROR = 1, CALL_UNEXPECTED_ERROR = 2, CALL_CANCELLE let libraryLoaded = false; /** * Loads the dynamic library from disk into memory. - * {% if out_disable_auto_loading_lib -%}NOTE: this must be called before any other functions in this module are called.{%- endif %} + * {% if out_lib_disable_auto_loading -%}NOTE: this must be called before any other functions in this module are called.{%- endif %} */ function _uniffiLoad() { const library = "lib{{ ci.crate_name() }}"; @@ -40,13 +44,31 @@ function _uniffiLoad() { console.warn("Unsupported platform:", platform); ext = "so"; } - {% match out_dirname_api %} - {% when DirnameApi::Dirname %} - const libraryDirectory = __dirname; - {% when DirnameApi::ImportMetaUrl %} - const libraryDirectory = dirname(fileURLToPath(import.meta.url)); + + {% match out_dirname_api -%} + {%- when DirnameApi::Dirname -%} + const libraryDirectory = __dirname; + {%- when DirnameApi::ImportMetaUrl -%} + const libraryDirectory = dirname(fileURLToPath(import.meta.url)); + {%- endmatch %} + + // Get the path to the lib to load + {% match out_lib_path -%} + {%- when LibPath::Omitted -%} + const libraryPath = join(libraryDirectory, `${library}.${ext}`); + + {% when LibPath::Literal(literal) %} + {%- if literal.is_absolute() -%} + const libraryPath = "{{ literal }}"; + {%- else -%} + const libraryPath = join(libraryDirectory, "{{ literal }}"); + {%- endif -%} + + {% when LibPath::Modules(_) %} + const libraryPath = (getLibPathModule() as { triple: string, path: string }).path; + {% endmatch %} - const libraryPath = join(libraryDirectory, `${library}.${ext}`); + open({ library, path: libraryPath }); libraryLoaded = true; } @@ -66,7 +88,7 @@ function _checkUniffiLoaded() { } } -{% if out_disable_auto_loading_lib %} +{% if out_lib_disable_auto_loading %} export { _uniffiLoad as uniffiLoad, _uniffiUnload as uniffiUnload }; {% else %} _uniffiLoad();