diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5169784..eb3fe3bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,9 +141,14 @@ jobs: - name: Generate MyDemo via CLI run: | - SAILS_CLI_TEMPLATES_BRANCH=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}} ./target/debug/cargo-sails sails new-program ~/tmp --name my-demo + SAILS_CLI_TEMPLATES_BRANCH=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}} ./target/debug/cargo-sails sails program ~/tmp --name my-demo - name: Run Tests on MyDemo run: | cd ~/tmp/my-demo cargo test -p my-demo + + - name: Generate IDL from MyDemo via CLI + run: | + ./target/debug/cargo-sails sails idl --manifest-path ~/tmp/my-demo/Cargo.toml + diff ~/tmp/my-demo/target/my-demo-app.idl ~/tmp/my-demo/target/wasm32-unknown-unknown/debug/my_demo.idl diff --git a/Cargo.lock b/Cargo.lock index 0c31a903..32b815ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5973,6 +5973,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rustdoc-types" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df53bab0198f33fc88c110aaabdb2df2cfee4b8a72aeaf942088e12bb305142" +dependencies = [ + "serde", +] + [[package]] name = "rustix" version = "0.36.17" @@ -6167,8 +6176,12 @@ version = "0.6.1" dependencies = [ "anyhow", "cargo-generate", + "cargo_metadata", "clap", + "rustdoc-types", "sails-client-gen", + "serde_json", + "toml_edit 0.22.22", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8789d8b8..e8872567 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ gwasm-builder = { version = "=1.6.2", package = "gear-wasm-builder" } # Other deps in alphabetical order anyhow = "1" cargo-generate = "0.21" +cargo_metadata = "0.18" clap = "4.5" convert-case = { package = "convert_case", version = "0.6" } futures = { version = "0.3", default-features = false } @@ -70,6 +71,7 @@ parity-scale-codec = { version = "3.6", default-features = false } prettyplease = "0.2" proc-macro-error = "1.0" proc-macro2 = { version = "1", default-features = false } +rustdoc-types = "=0.29.1" quote = "1.0" scale-info = { version = "2.11", default-features = false } serde = "1.0" @@ -78,5 +80,6 @@ spin = { version = "0.9", default-features = false, features = ["spin_mutex"] } syn = "2.0" thiserror = "1.0" thiserror-no-std = "2.0" +toml_edit = "0.22" tokio = "1.40" trybuild = "1" diff --git a/rs/cli/Cargo.toml b/rs/cli/Cargo.toml index 5bb7e5f8..dc0de311 100644 --- a/rs/cli/Cargo.toml +++ b/rs/cli/Cargo.toml @@ -14,5 +14,9 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true cargo-generate.workspace = true +cargo_metadata.workspace = true clap = { workspace = true, features = ["derive"] } +rustdoc-types.workspace = true sails-client-gen.workspace = true +serde-json.workspace = true +toml_edit.workspace = true diff --git a/rs/cli/src/idlgen.rs b/rs/cli/src/idlgen.rs new file mode 100644 index 00000000..92534128 --- /dev/null +++ b/rs/cli/src/idlgen.rs @@ -0,0 +1,410 @@ +use anyhow::Context; +use cargo_metadata::{camino::*, Package, PackageId}; +use std::{ + collections::{HashMap, HashSet}, + env, fs, + path::{Path, PathBuf}, + process::{Command, ExitStatus}, +}; + +pub struct CrateIdlGenerator { + manifest_path: Utf8PathBuf, + target_dir: Option, + deps_level: usize, +} + +impl CrateIdlGenerator { + pub fn new( + manifest_path: Option, + target_dir: Option, + deps_level: Option, + ) -> Self { + Self { + manifest_path: Utf8PathBuf::from_path_buf( + manifest_path.unwrap_or_else(|| env::current_dir().unwrap().join("Cargo.toml")), + ) + .unwrap(), + target_dir: target_dir + .and_then(|p| p.canonicalize().ok()) + .map(Utf8PathBuf::from_path_buf) + .and_then(|t| t.ok()), + deps_level: deps_level.unwrap_or(1), + } + } + + pub fn generate(self) -> anyhow::Result<()> { + println!("...reading metadata: {}", &self.manifest_path); + // get metadata with deps + let metadata = cargo_metadata::MetadataCommand::new() + .manifest_path(&self.manifest_path) + .exec()?; + + // find `sails-rs` packages (any version ) + let sails_packages = metadata + .packages + .iter() + .filter(|&p| p.name == "sails-rs") + .collect::>(); + + let target_dir = self + .target_dir + .as_ref() + .unwrap_or(&metadata.target_directory); + + let package_list = get_package_list(&metadata, self.deps_level)?; + println!( + "...looking for Program implemetation in {} package(s)", + package_list.len() + ); + for program_package in package_list { + let idl_gen = PackageIdlGenerator::new( + program_package, + &sails_packages, + target_dir, + &metadata.workspace_root, + ); + match get_program_struct_path_from_doc(program_package, target_dir) { + Ok((program_struct_path, meta_path_version)) => { + println!("...found Program implemetation: {}", program_struct_path); + let file_path = idl_gen + .try_generate_for_package(&program_struct_path, meta_path_version)?; + println!("Generated IDL: {}", file_path); + + return Ok(()); + } + Err(err) => { + println!("...no Program implementation found: {}", err); + } + } + } + Err(anyhow::anyhow!("no Program implementation found")) + } +} + +struct PackageIdlGenerator<'a> { + program_package: &'a Package, + sails_packages: &'a Vec<&'a Package>, + target_dir: &'a Utf8Path, + workspace_root: &'a Utf8Path, +} + +impl<'a> PackageIdlGenerator<'a> { + fn new( + program_package: &'a Package, + sails_packages: &'a Vec<&'a Package>, + target_dir: &'a Utf8Path, + workspace_root: &'a Utf8Path, + ) -> Self { + Self { + program_package, + sails_packages, + target_dir, + workspace_root, + } + } + + fn try_generate_for_package( + &self, + program_struct_path: &str, + meta_path_version: MetaPathVersion, + ) -> anyhow::Result { + // find `sails-rs` dependency + let sails_dep = self + .program_package + .dependencies + .iter() + .find(|p| p.name == "sails-rs") + .context("failed to find `sails-rs` dependency")?; + // find `sails-rs` package matches dep version + let sails_package = self + .sails_packages + .iter() + .find(|p| sails_dep.req.matches(&p.version)) + .context(format!( + "failed to find `sails-rs` package with matching version {}", + &sails_dep.req + ))?; + + let crate_name = get_idl_gen_crate_name(self.program_package); + let crate_dir = &self.target_dir.join(&crate_name); + let src_dir = crate_dir.join("src"); + fs::create_dir_all(&src_dir)?; + + let gen_manifest_path = crate_dir.join("Cargo.toml"); + write_file( + &gen_manifest_path, + gen_cargo_toml(self.program_package, sails_package, meta_path_version), + )?; + + let out_file = self + .target_dir + .join(format!("{}.idl", &self.program_package.name)); + let main_rs_path = src_dir.join("main.rs"); + write_file(main_rs_path, gen_main_rs(program_struct_path, &out_file))?; + + let from_lock = &self.workspace_root.join("Cargo.lock"); + let to_lock = &crate_dir.join("Cargo.lock"); + drop(fs::copy(from_lock, to_lock)); + + let res = cargo_run_bin(&gen_manifest_path, &crate_name, self.target_dir); + + fs::remove_dir_all(crate_dir)?; + + match res { + Ok(exit_status) if exit_status.success() => Ok(out_file), + Ok(exit_status) => Err(anyhow::anyhow!("Exit status: {}", exit_status)), + Err(err) => Err(err), + } + } +} + +/// Get list of packages from the root package and its dependencies +fn get_package_list( + metadata: &cargo_metadata::Metadata, + deps_level: usize, +) -> Result, anyhow::Error> { + let resolve = metadata + .resolve + .as_ref() + .context("failed to get resolve from metadata")?; + let root_package_id = resolve + .root + .as_ref() + .context("failed to find root package")?; + let node_map = resolve + .nodes + .iter() + .map(|n| (&n.id, n)) + .collect::>(); + let package_map = metadata + .packages + .iter() + .map(|p| (&p.id, p)) + .collect::>(); + + let mut deps_set: HashSet<&PackageId> = HashSet::new(); + deps_set.insert(root_package_id); + + let mut deps = vec![root_package_id]; + for _ in 0..deps_level { + deps = deps + .iter() + .filter_map(|id| node_map.get(id)) + .flat_map(|&n| &n.dependencies) + .filter(|&id| metadata.workspace_members.contains(id)) + .collect(); + if deps.is_empty() { + break; + } + deps_set.extend(deps.iter()); + } + let package_list: Vec<&Package> = deps_set + .iter() + .filter_map(|id| package_map.get(id)) + .copied() + .collect(); + Ok(package_list) +} + +fn get_program_struct_path_from_doc( + program_package: &Package, + target_dir: &Utf8Path, +) -> anyhow::Result<(String, MetaPathVersion)> { + let program_package_file_name = program_package.name.to_lowercase().replace('-', "_"); + println!( + "...running doc generation for `{}`", + program_package.manifest_path + ); + // run `cargo doc` + _ = cargo_doc(&program_package.manifest_path, target_dir)?; + // read doc + let docs_path = target_dir + .join("doc") + .join(format!("{}.json", &program_package_file_name)); + println!("...reading doc: {}", docs_path); + let json_string = std::fs::read_to_string(docs_path)?; + let doc_crate: rustdoc_types::Crate = serde_json::from_str(&json_string)?; + + // find `sails_rs::meta::ProgramMeta` path id + let (program_meta_id, meta_path_version) = doc_crate + .paths + .iter() + .find_map(|(id, summary)| MetaPathVersion::matches(&summary.path).map(|v| (id, v))) + .context("failed to find `sails_rs::meta::ProgramMeta` definition in dependencies")?; + // find struct implementing `sails_rs::meta::ProgramMeta` + let program_struct_path = doc_crate + .index + .values() + .find_map(|idx| try_get_trait_implementation_path(idx, program_meta_id)) + .context("failed to find `sails_rs::meta::ProgramMeta` implemetation")?; + let program_struct = doc_crate + .paths + .get(&program_struct_path.id) + .context("failed to get Program struct by id")?; + let program_struct_path = program_struct.path.join("::"); + Ok((program_struct_path, meta_path_version)) +} + +fn try_get_trait_implementation_path( + idx: &rustdoc_types::Item, + program_meta_id: &rustdoc_types::Id, +) -> Option { + if let rustdoc_types::ItemEnum::Impl(item) = &idx.inner { + if let Some(tp) = &item.trait_ { + if &tp.id == program_meta_id { + if let rustdoc_types::Type::ResolvedPath(path) = &item.for_ { + return Some(path.clone()); + } + } + } + } + None +} + +fn get_idl_gen_crate_name(program_package: &Package) -> String { + format!("{}-idl-gen", program_package.name) +} + +fn write_file, C: AsRef<[u8]>>(path: P, contents: C) -> anyhow::Result<()> { + let path = path.as_ref(); + fs::write(path, contents.as_ref()) + .with_context(|| format!("failed to write `{}`", path.display())) +} + +fn cargo_doc( + manifest_path: &cargo_metadata::camino::Utf8Path, + target_dir: &cargo_metadata::camino::Utf8Path, +) -> anyhow::Result { + let cargo_path = std::env::var("CARGO").unwrap_or("cargo".into()); + + let mut cmd = Command::new(cargo_path); + cmd.env("RUSTC_BOOTSTRAP", "1") + .env( + "RUSTDOCFLAGS", + "-Z unstable-options --output-format=json --cap-lints=allow", + ) + .env("__GEAR_WASM_BUILDER_NO_BUILD", "1") + .stdout(std::process::Stdio::null()) // Don't pollute output + .arg("doc") + .arg("--manifest-path") + .arg(manifest_path.as_str()) + .arg("--target-dir") + .arg(target_dir.as_str()) + .arg("--no-deps") + .arg("--quiet"); + + cmd.status() + .context("failed to execute `cargo doc` command") +} + +fn cargo_run_bin( + manifest_path: &cargo_metadata::camino::Utf8Path, + bin_name: &str, + target_dir: &cargo_metadata::camino::Utf8Path, +) -> anyhow::Result { + let cargo_path = std::env::var("CARGO").unwrap_or("cargo".into()); + + let mut cmd = Command::new(cargo_path); + cmd.env("CARGO_TARGET_DIR", target_dir) + .env("__GEAR_WASM_BUILDER_NO_BUILD", "1") + .stdout(std::process::Stdio::null()) // Don't pollute output + .arg("run") + .arg("--manifest-path") + .arg(manifest_path.as_str()) + .arg("--bin") + .arg(bin_name); + cmd.status().context("failed to execute `cargo` command") +} + +enum MetaPathVersion { + V1, + V2, +} + +impl MetaPathVersion { + const META_PATH_V1: &[&str] = &["sails_rs", "meta", "ProgramMeta"]; + const META_PATH_V2: &[&str] = &["sails_idl_meta", "ProgramMeta"]; + + fn matches(path: &Vec) -> Option { + if path == Self::META_PATH_V1 { + Some(MetaPathVersion::V1) + } else if path == Self::META_PATH_V2 { + Some(MetaPathVersion::V2) + } else { + None + } + } +} + +fn gen_cargo_toml( + program_package: &Package, + sails_package: &Package, + meta_path_version: MetaPathVersion, +) -> String { + let mut manifest = toml_edit::DocumentMut::new(); + manifest["package"] = toml_edit::Item::Table(toml_edit::Table::new()); + manifest["package"]["name"] = toml_edit::value(get_idl_gen_crate_name(program_package)); + manifest["package"]["version"] = toml_edit::value("0.1.0"); + manifest["package"]["edition"] = toml_edit::value(program_package.edition.as_str()); + + let mut dep_table = toml_edit::Table::default(); + let mut package_table = toml_edit::InlineTable::new(); + let manifets_dir = program_package.manifest_path.parent().unwrap(); + package_table.insert("path", manifets_dir.as_str().into()); + dep_table[&program_package.name] = toml_edit::value(package_table); + + let sails_dep = match meta_path_version { + MetaPathVersion::V1 => sails_dep_v1(sails_package), + MetaPathVersion::V2 => sails_dep_v2(sails_package), + }; + dep_table[&sails_package.name] = toml_edit::value(sails_dep); + + manifest["dependencies"] = toml_edit::Item::Table(dep_table); + + let mut bin = toml_edit::Table::new(); + bin["name"] = toml_edit::value(get_idl_gen_crate_name(program_package)); + bin["path"] = toml_edit::value("src/main.rs"); + manifest["bin"] + .or_insert(toml_edit::Item::ArrayOfTables( + toml_edit::ArrayOfTables::new(), + )) + .as_array_of_tables_mut() + .expect("bin is an array of tables") + .push(bin); + + manifest["workspace"] = toml_edit::Item::Table(toml_edit::Table::new()); + + manifest.to_string() +} + +fn sails_dep_v1(sails_package: &Package) -> toml_edit::InlineTable { + let mut sails_table = toml_edit::InlineTable::new(); + sails_table.insert("package", "sails-idl-gen".into()); + sails_table.insert("version", sails_package.version.to_string().into()); + sails_table +} + +fn sails_dep_v2(sails_package: &Package) -> toml_edit::InlineTable { + let mut features = toml_edit::Array::default(); + features.push("idl-gen"); + let mut sails_table = toml_edit::InlineTable::new(); + let manifets_dir = sails_package.manifest_path.parent().unwrap(); + sails_table.insert("package", sails_package.name.as_str().into()); + sails_table.insert("path", manifets_dir.as_str().into()); + sails_table.insert("features", features.into()); + sails_table +} + +fn gen_main_rs(program_struct_path: &str, out_file: &cargo_metadata::camino::Utf8Path) -> String { + format!( + " +fn main() {{ + sails_rs::generate_idl_to_file::<{}>( + std::path::PathBuf::from(r\"{}\") + ) + .unwrap(); +}}", + program_struct_path, + out_file.as_str(), + ) +} diff --git a/rs/cli/src/lib.rs b/rs/cli/src/lib.rs index e2c04eaa..36af1f5d 100644 --- a/rs/cli/src/lib.rs +++ b/rs/cli/src/lib.rs @@ -1 +1,2 @@ +pub mod idlgen; pub mod program; diff --git a/rs/cli/src/main.rs b/rs/cli/src/main.rs index 3bf82a41..ca05be55 100644 --- a/rs/cli/src/main.rs +++ b/rs/cli/src/main.rs @@ -1,5 +1,5 @@ use clap::{Parser, Subcommand}; -use sails_cli::program::ProgramGenerator; +use sails_cli::{idlgen::CrateIdlGenerator, program::ProgramGenerator}; use sails_client_gen::ClientGenerator; use std::{error::Error, path::PathBuf}; @@ -13,7 +13,7 @@ enum CliCommand { #[derive(Subcommand)] enum SailsCommands { /// Create a new program from template - #[command(name = "new-program")] + #[command(name = "program")] NewProgram { #[arg(help = "Path to the new program")] path: String, @@ -50,6 +50,20 @@ enum SailsCommands { #[arg(long)] no_derive_traits: bool, }, + + /// Generate IDL from Cargo manifest + #[command(name = "idl")] + IdlGen { + /// Path to the crate with program + #[arg(long, value_hint = clap::ValueHint::FilePath)] + manifest_path: Option, + /// Directory for all generated artifacts + #[arg(long, value_hint = clap::ValueHint::DirPath)] + target_dir: Option, + /// Level of dependencies to look for program implementation. Default: 1 + #[arg(long)] + deps_level: Option, + }, } /// Parse a single key-value pair @@ -106,6 +120,11 @@ fn main() -> Result<(), i32> { let out_path = out_path.unwrap_or_else(|| idl_path.with_extension("rs")); client_gen.generate_to(out_path) } + SailsCommands::IdlGen { + manifest_path, + target_dir, + deps_level, + } => CrateIdlGenerator::new(manifest_path, target_dir, deps_level).generate(), }; if let Err(e) = result {