diff --git a/Cargo.lock b/Cargo.lock index b85cd85a5b..15d328eda9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1063,7 +1063,14 @@ checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be" name = "embedded-uniffi-bindgen" version = "0.1.0" dependencies = [ - "uniffi", + "anyhow", + "camino", + "clap", + "glob", + "serde", + "toml", + "uniffi_bindgen", + "uniffi_pipeline", ] [[package]] diff --git a/tools/embedded-uniffi-bindgen/Cargo.toml b/tools/embedded-uniffi-bindgen/Cargo.toml index 68a0c9bbea..ac945c5c11 100644 --- a/tools/embedded-uniffi-bindgen/Cargo.toml +++ b/tools/embedded-uniffi-bindgen/Cargo.toml @@ -10,4 +10,11 @@ license = "MPL-2.0" name = "embedded-uniffi-bindgen" [dependencies] -uniffi = { version = "0.29.0", features = ["cli"] } +anyhow = "1" +camino = "1" +glob = "0.3" +toml = "0.5" +clap = {version = "4.2", default-features = false, features = ["std", "derive"]} +serde = { version = "1", features = ["derive"] } +uniffi_bindgen = { version = "0.29.3" } +uniffi_pipeline = { version = "0.29.3" } diff --git a/tools/embedded-uniffi-bindgen/src/config_supplier.rs b/tools/embedded-uniffi-bindgen/src/config_supplier.rs new file mode 100644 index 0000000000..bd49014f76 --- /dev/null +++ b/tools/embedded-uniffi-bindgen/src/config_supplier.rs @@ -0,0 +1,186 @@ +/* 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/. */ + +// This is the only significant difference from vanilla UniFFI, +// +// We define our own config supplier than parses `Cargo.toml` files directly to avoid a dependency +// on `cargo`. This code tries to parse as little as possible to make this work. +// +// We could move this code into the main uniffi repo and add a flag to use it instead of the +// cargo-based config supplier. + +use std::{ + collections::{HashMap, HashSet}, + env, fs, + sync::LazyLock, +}; + +use anyhow::{anyhow, Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use serde::Deserialize; +use uniffi_bindgen::BindgenCrateConfigSupplier; + +pub struct NoCargoConfigSupplier; + +impl BindgenCrateConfigSupplier for NoCargoConfigSupplier { + fn get_toml(&self, crate_name: &str) -> Result> { + match self.get_toml_path(crate_name) { + None => Ok(None), + Some(path) => Ok(Some(toml::from_str(&fs::read_to_string(path)?)?)), + } + } + + fn get_toml_path(&self, crate_name: &str) -> Option { + let crate_map = CRATE_MAP.as_ref().expect("Error parsing Cargo.toml files"); + let crate_root = crate_map.get(crate_name)?; + let toml_path = crate_root.join("uniffi.toml"); + toml_path.exists().then_some(toml_path) + } + + /// Obtains the contents of the named UDL file which was referenced by the type metadata. + fn get_udl(&self, crate_name: &str, udl_name: &str) -> Result { + let crate_map = CRATE_MAP.as_ref().expect("Error parsing Cargo.toml files"); + let crate_root = crate_map + .get(crate_name) + .ok_or_else(|| anyhow!("Unknown crate: {crate_name}"))?; + let udl_path = crate_root.join(format!("src/{udl_name}.udl")); + fs::read_to_string(&udl_path).context(format!("Error reading {udl_path}")) + } +} + +static CRATE_MAP: LazyLock>> = + LazyLock::new(find_workspace_crates); + +/// Find all crates in this workspace and return a map of crate_name => crate_root_path +fn find_workspace_crates() -> Result> { + let workspace_toml = find_workspace_toml()?; + + let mut toml_paths_to_process = vec![]; + for path in workspace_toml.data.workspace.unwrap().members { + toml_paths_to_process.extend(join_and_glob(&workspace_toml.dir, path)?) + } + let mut toml_paths_processed = HashSet::new(); + let mut map = HashMap::new(); + + loop { + let Some(crate_dir) = toml_paths_to_process.pop() else { + break; + }; + let toml_path = join(&crate_dir, "Cargo.toml")?; + if !toml_paths_processed.insert(toml_path.clone()) { + continue; + } + + let toml = CargoToml::from_path(&toml_path)?; + let new_paths = find_other_cargo_toml_paths(&crate_dir, &toml)?; + toml_paths_to_process.extend(new_paths); + + // Add both the package name and library name to the map + if let Some(package) = toml.package { + map.insert(package.name.replace("-", "_"), crate_dir.clone()); + } + + if let Some(CargoLibrary { name: Some(name) }) = toml.lib { + map.insert(name.replace("-", "_"), crate_dir); + } + } + Ok(map) +} + +/// Find the workspace Cargo.toml file. +/// +/// Returns the parsed TOML data plus the directory of the file +fn find_workspace_toml() -> Result { + let current_dir = camino::Utf8PathBuf::from_path_buf(env::current_dir()?) + .map_err(|_| anyhow!("path is not UTF8"))?; + let mut dir = current_dir.as_path(); + loop { + let path = dir.join("Cargo.toml"); + if path.exists() { + let toml = CargoToml::from_path(&path)?; + if toml.workspace.is_some() { + return Ok(CargoTomlFile { + data: toml, + dir: dir.to_path_buf(), + }); + } + } + dir = dir + .parent() + .ok_or_else(|| anyhow!("Couldn't find workspace Cargo.toml"))?; + } +} + +/// Process Cargo.toml data and return all crate paths referenced in it +fn find_other_cargo_toml_paths(crate_dir: &Utf8Path, toml: &CargoToml) -> Result> { + toml.dependencies + .iter() + .flat_map(|d| d.values()) + .filter_map(|dep| match dep { + CargoDependency::Detailed { path: Some(path) } => Some(join(crate_dir, path)), + _ => None, + }) + .collect() +} + +fn join(dir: &Utf8Path, child: impl AsRef) -> Result { + let child = child.as_ref(); + dir.join(child) + .canonicalize_utf8() + .map_err(|p| anyhow!("Invalid path: {p} {dir}, {child}")) +} + +fn join_and_glob(dir: &Utf8Path, child: impl AsRef) -> Result> { + let child = child.as_ref(); + glob::glob(dir.join(child).as_str())? + .map(|entry| anyhow::Ok(Utf8PathBuf::try_from(entry?)?)) + .map(|path| Ok(path?.canonicalize_utf8()?)) + .collect() +} + +#[derive(Debug)] +struct CargoTomlFile { + data: CargoToml, + dir: Utf8PathBuf, +} + +#[derive(Debug, Deserialize)] +struct CargoToml { + package: Option, + lib: Option, + workspace: Option, + dependencies: Option>, +} + +impl CargoToml { + fn from_path(path: &Utf8Path) -> Result { + let contents = fs::read_to_string(path).context(format!("reading {path}"))?; + toml::from_str(&contents).context(format!("parsing {path}")) + } +} + +#[derive(Debug, Deserialize)] +struct CargoPackage { + name: String, +} + +#[derive(Debug, Deserialize)] +struct CargoLibrary { + name: Option, +} + +#[derive(Debug, Deserialize)] +struct CargoWorkspace { + members: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum CargoDependency { + #[allow(dead_code)] + Simple(String), + Detailed { + path: Option, + }, +} diff --git a/tools/embedded-uniffi-bindgen/src/main.rs b/tools/embedded-uniffi-bindgen/src/main.rs index 9ceeccea51..e15e0e155c 100644 --- a/tools/embedded-uniffi-bindgen/src/main.rs +++ b/tools/embedded-uniffi-bindgen/src/main.rs @@ -2,6 +2,9 @@ * 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/. */ -fn main() { - uniffi::uniffi_bindgen_main() +pub mod config_supplier; +mod uniffi_bindgen; + +fn main() -> anyhow::Result<()> { + uniffi_bindgen::run_main() } diff --git a/tools/embedded-uniffi-bindgen/src/uniffi_bindgen.rs b/tools/embedded-uniffi-bindgen/src/uniffi_bindgen.rs new file mode 100644 index 0000000000..12afd5f3d7 --- /dev/null +++ b/tools/embedded-uniffi-bindgen/src/uniffi_bindgen.rs @@ -0,0 +1,364 @@ +/* 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/. */ + +// Copied from uniffi-rs/uniffi/src/cli/uniffi_bindgen.rs (v0.29.3) and lightly modified. + +use anyhow::{bail, Result}; +use camino::Utf8PathBuf; +use clap::{Args, Parser, Subcommand, ValueEnum}; +use std::fmt; +use uniffi_bindgen::bindings::*; +use uniffi_bindgen::pipeline::initial; +use uniffi_pipeline::PrintOptions; + +/// Enumeration of all foreign language targets currently supported by our CLI. +/// +#[derive(Copy, Clone, Eq, PartialEq, Hash, ValueEnum)] +enum TargetLanguage { + Kotlin, + Swift, + Python, + Ruby, +} + +impl fmt::Display for TargetLanguage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Kotlin => write!(f, "kotlin"), + Self::Swift => write!(f, "swift"), + Self::Python => write!(f, "python"), + Self::Ruby => write!(f, "ruby"), + } + } +} + +impl TryFrom<&str> for TargetLanguage { + type Error = anyhow::Error; + fn try_from(value: &str) -> Result { + Ok(match value.to_ascii_lowercase().as_str() { + "kotlin" | "kt" | "kts" => TargetLanguage::Kotlin, + "swift" => TargetLanguage::Swift, + "python" | "py" => TargetLanguage::Python, + "ruby" | "rb" => TargetLanguage::Ruby, + _ => bail!("Unknown or unsupported target language: \"{value}\""), + }) + } +} + +impl TryFrom<&std::ffi::OsStr> for TargetLanguage { + type Error = anyhow::Error; + fn try_from(value: &std::ffi::OsStr) -> Result { + match value.to_str() { + None => bail!("Unreadable target language"), + Some(s) => s.try_into(), + } + } +} + +impl TryFrom for TargetLanguage { + type Error = anyhow::Error; + fn try_from(value: String) -> Result { + TryFrom::try_from(value.as_str()) + } +} + +// Structs to help our cmdline parsing. Note that docstrings below form part +// of the "help" output. + +/// Scaffolding and bindings generator for Rust +#[derive(Parser)] +#[clap(name = "uniffi-bindgen")] +struct Cli { + #[clap(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Generate foreign language bindings + Generate { + /// Foreign language(s) for which to build bindings. + #[clap(long, short, value_enum)] + language: Vec, + + /// Directory in which to write generated files. Default is same folder as .udl file. + #[clap(long, short)] + out_dir: Option, + + /// Do not try to format the generated bindings. + #[clap(long, short)] + no_format: bool, + + /// Path to optional uniffi config file. This config is merged with the `uniffi.toml` config present in each crate, with its values taking precedence. + #[clap(long, short)] + config: Option, + + /// Extract proc-macro metadata from a native lib (cdylib or staticlib) for this crate. + #[clap(long)] + lib_file: Option, + + /// Pass in a cdylib path rather than a UDL file + #[clap(long = "library")] + library_mode: bool, + + /// When `--library` is passed, only generate bindings for one crate. + /// When `--library` is not passed, use this as the crate name instead of attempting to + /// locate and parse Cargo.toml. + #[clap(long = "crate")] + crate_name: Option, + + /// Path to the UDL file, or cdylib if `library-mode` is specified + source: Utf8PathBuf, + }, + + /// Generate Rust scaffolding code + Scaffolding { + /// Directory in which to write generated files. Default is same folder as .udl file. + #[clap(long, short)] + out_dir: Option, + + /// Do not try to format the generated bindings. + #[clap(long, short)] + no_format: bool, + + /// Path to the UDL file. + udl_file: Utf8PathBuf, + }, + + /// Inspect the bindings render pipeline + Pipeline(PipelineArgs), +} + +#[derive(Args)] +struct PipelineArgs { + /// Pass in a cdylib path rather than a UDL file + #[clap(long = "library")] + library_mode: bool, + + /// Path to the UDL file, or cdylib if `library-mode` is specified + source: Utf8PathBuf, + + /// When `--library` is passed, only generate bindings for one crate. + /// When `--library` is not passed, use this as the crate name instead of attempting to + /// locate and parse Cargo.toml. + #[clap(long = "crate")] + crate_name: Option, + + /// Bindings Language + language: TargetLanguage, + + /// Only show passes that match + /// + /// Use `last` to only show the last pass, this can be useful when you're writing new pipelines + #[clap(short, long)] + pass: Option, + + /// Don't show diffs for middle passes + #[clap(long)] + no_diff: bool, + + /// Only show data for types with name + #[clap(short = 't', long = "type")] + filter_type: Option, + + /// Only show data for items with fields that match + #[clap(short = 'n', long = "name")] + filter_name: Option, +} + +fn gen_library_mode( + library_path: &camino::Utf8Path, + crate_name: Option, + languages: Vec, + cfo: Option<&camino::Utf8Path>, + out_dir: &camino::Utf8Path, + fmt: bool, +) -> anyhow::Result<()> { + use uniffi_bindgen::library_mode::generate_bindings; + + let config_supplier = crate::config_supplier::NoCargoConfigSupplier; + + for language in languages { + // to help avoid mistakes we check the library is actually a cdylib, except + // for swift where static libs are often used to extract the metadata. + if !matches!(language, TargetLanguage::Swift) && !uniffi_bindgen::is_cdylib(library_path) { + anyhow::bail!( + "Generate bindings for {language} requires a cdylib, but {library_path} was given" + ); + } + + // Type-bounds on trait implementations makes selecting between languages a bit tedious. + match language { + TargetLanguage::Kotlin => generate_bindings( + library_path, + crate_name.clone(), + &KotlinBindingGenerator, + &config_supplier, + cfo, + out_dir, + fmt, + )? + .len(), + TargetLanguage::Python => generate_bindings( + library_path, + crate_name.clone(), + &PythonBindingGenerator, + &config_supplier, + cfo, + out_dir, + fmt, + )? + .len(), + TargetLanguage::Ruby => generate_bindings( + library_path, + crate_name.clone(), + &RubyBindingGenerator, + &config_supplier, + cfo, + out_dir, + fmt, + )? + .len(), + TargetLanguage::Swift => generate_bindings( + library_path, + crate_name.clone(), + &SwiftBindingGenerator, + &config_supplier, + cfo, + out_dir, + fmt, + )? + .len(), + }; + } + Ok(()) +} + +fn gen_bindings( + udl_file: &camino::Utf8Path, + cfo: Option<&camino::Utf8Path>, + languages: Vec, + odo: Option<&camino::Utf8Path>, + library_file: Option<&camino::Utf8Path>, + crate_name: Option<&str>, + fmt: bool, +) -> anyhow::Result<()> { + use uniffi_bindgen::generate_bindings; + for language in languages { + match language { + TargetLanguage::Kotlin => generate_bindings( + udl_file, + cfo, + KotlinBindingGenerator, + odo, + library_file, + crate_name, + fmt, + )?, + TargetLanguage::Python => generate_bindings( + udl_file, + cfo, + PythonBindingGenerator, + odo, + library_file, + crate_name, + fmt, + )?, + TargetLanguage::Ruby => generate_bindings( + udl_file, + cfo, + RubyBindingGenerator, + odo, + library_file, + crate_name, + fmt, + )?, + TargetLanguage::Swift => generate_bindings( + udl_file, + cfo, + SwiftBindingGenerator, + odo, + library_file, + crate_name, + fmt, + )?, + }; + } + Ok(()) +} + +pub fn run_main() -> anyhow::Result<()> { + let cli = Cli::parse(); + match cli.command { + Commands::Generate { + language, + out_dir, + no_format, + config, + lib_file, + source, + crate_name, + library_mode, + } => { + if library_mode { + if lib_file.is_some() { + panic!("--lib-file is not compatible with --library.") + } + let out_dir = out_dir.expect("--out-dir is required when using --library"); + if language.is_empty() { + panic!("please specify at least one language with --language") + } + gen_library_mode( + &source, + crate_name, + language, + config.as_deref(), + &out_dir, + !no_format, + )?; + } else { + gen_bindings( + &source, + config.as_deref(), + language, + out_dir.as_deref(), + lib_file.as_deref(), + crate_name.as_deref(), + !no_format, + )?; + } + } + Commands::Scaffolding { + out_dir, + no_format, + udl_file, + } => { + uniffi_bindgen::generate_component_scaffolding( + &udl_file, + out_dir.as_deref(), + !no_format, + )?; + } + Commands::Pipeline(args) => { + let config_supplier = crate::config_supplier::NoCargoConfigSupplier; + let initial_root = if args.library_mode { + initial::Root::from_library(config_supplier, &args.source, args.crate_name)? + } else { + initial::Root::from_udl(config_supplier, &args.source, args.crate_name)? + }; + + let opts = PrintOptions { + pass: args.pass, + no_diff: args.no_diff, + filter_type: args.filter_type, + filter_name: args.filter_name, + }; + match args.language { + TargetLanguage::Python => python::pipeline().print_passes(initial_root, opts)?, + language => unimplemented!("{language} does not use the bindings IR pipeline yet"), + }; + } + }; + Ok(()) +}