Skip to main content

sails_cli/
idlgen.rs

1use anyhow::{Context, bail};
2use cargo_metadata::{DependencyKind, Package, PackageId, camino::*, semver::VersionReq};
3use convert_case::{Case, Casing};
4use std::{
5    collections::{HashMap, HashSet},
6    env, fs,
7    path::{Path, PathBuf},
8    process::{Command, ExitStatus},
9};
10
11const META_PATH: &[&str] = &["sails_idl_meta", "ProgramMeta"];
12const ICON_BUILD: &str = "🔨";
13const ICON_DONE: &str = "✅";
14const ICON_ERROR: &str = "❌";
15
16pub struct CrateIdlGenerator {
17    manifest_path: Utf8PathBuf,
18    target_dir: Option<Utf8PathBuf>,
19    deps_level: usize,
20    program_name: Option<String>,
21}
22
23impl CrateIdlGenerator {
24    pub fn new(
25        manifest_path: Option<PathBuf>,
26        target_dir: Option<PathBuf>,
27        deps_level: Option<usize>,
28        program_name: Option<String>,
29    ) -> Self {
30        Self {
31            manifest_path: Utf8PathBuf::from_path_buf(
32                manifest_path.unwrap_or_else(|| env::current_dir().unwrap().join("Cargo.toml")),
33            )
34            .unwrap(),
35            target_dir: target_dir
36                .and_then(|p| p.canonicalize().ok())
37                .map(Utf8PathBuf::from_path_buf)
38                .and_then(|t| t.ok()),
39            deps_level: deps_level.unwrap_or(1),
40            program_name,
41        }
42    }
43
44    pub fn generate(self) -> anyhow::Result<()> {
45        println!("...reading metadata: {}", &self.manifest_path);
46        // get metadata with deps
47        let metadata = cargo_metadata::MetadataCommand::new()
48            .manifest_path(&self.manifest_path)
49            .exec()?;
50
51        // find `sails-rs` packages (any version )
52        let sails_packages = metadata
53            .packages
54            .iter()
55            .filter(|&p| p.name == "sails-rs")
56            .collect::<Vec<_>>();
57        if sails_packages.is_empty() {
58            bail!("`sails-rs` package not found");
59        }
60
61        let target_dir = self
62            .target_dir
63            .as_ref()
64            .unwrap_or(&metadata.target_directory);
65
66        let package_list = get_package_list(&metadata, self.deps_level)?;
67        println!(
68            "...looking for Program implementation in {} package(s)",
69            package_list.len()
70        );
71        for program_package in package_list {
72            let idl_gen = PackageIdlGenerator::new(
73                program_package,
74                &sails_packages,
75                target_dir,
76                &metadata.workspace_root,
77                self.program_name.clone(),
78            );
79            match get_program_struct_path_from_doc(program_package, target_dir) {
80                Ok(program_struct_path) => {
81                    println!("...found Program implementation: {program_struct_path}");
82                    let file_path = idl_gen.try_generate_for_package(&program_struct_path)?;
83                    println!("{ICON_DONE} Generated IDL: {file_path}");
84
85                    return Ok(());
86                }
87                Err(err) => {
88                    println!("{ICON_ERROR} no Program implementation found: {err}");
89                }
90            }
91        }
92        Err(anyhow::anyhow!("no Program implementation found"))
93    }
94}
95
96struct PackageIdlGenerator<'a> {
97    program_package: &'a Package,
98    sails_packages: &'a Vec<&'a Package>,
99    target_dir: &'a Utf8Path,
100    workspace_root: &'a Utf8Path,
101    program_name: Option<String>,
102}
103
104impl<'a> PackageIdlGenerator<'a> {
105    fn new(
106        program_package: &'a Package,
107        sails_packages: &'a Vec<&'a Package>,
108        target_dir: &'a Utf8Path,
109        workspace_root: &'a Utf8Path,
110        program_name: Option<String>,
111    ) -> Self {
112        Self {
113            program_package,
114            sails_packages,
115            target_dir,
116            workspace_root,
117            program_name,
118        }
119    }
120
121    fn try_generate_for_package(&self, program_struct_path: &str) -> anyhow::Result<Utf8PathBuf> {
122        // find `sails-rs` dependency kind `Normal`
123        let sails_dep = self
124            .program_package
125            .dependencies
126            .iter()
127            .find(|p| p.name == "sails-rs" && p.kind == DependencyKind::Normal)
128            .context("failed to find `sails-rs` dependency")?;
129        // collect features, e.g. ["ethexe"]
130        let sails_features = &sails_dep.features;
131        println!(
132            "...found `sails-rs` dep with features: {:?}",
133            sails_features
134        );
135        // find `sails-rs` package matches dep version
136        let sails_package = self
137            .sails_packages
138            .iter()
139            .find(|p| sails_dep.req == VersionReq::STAR || sails_dep.req.matches(&p.version))
140            .context(format!(
141                "failed to find `sails-rs` package with matching version {}",
142                &sails_dep.req
143            ))?;
144
145        let crate_name = &get_idl_gen_crate_name(self.program_package);
146        let crate_dir = &self.target_dir.join(crate_name);
147        let src_dir = crate_dir.join("src");
148        fs::create_dir_all(&src_dir)?;
149
150        let gen_manifest_path = crate_dir.join("Cargo.toml");
151        write_file(
152            &gen_manifest_path,
153            gen_cargo_toml(self.program_package, sails_package, sails_features),
154        )?;
155
156        let out_file = self
157            .target_dir
158            .join(format!("{}.idl", &self.program_package.name));
159        let program_name = self
160            .program_name
161            .clone()
162            .unwrap_or_else(|| self.program_package.name.to_case(Case::Pascal));
163        let main_rs_path = src_dir.join("main.rs");
164        write_file(
165            main_rs_path,
166            gen_main_rs(program_struct_path, &program_name, &out_file),
167        )?;
168
169        let from_lock = &self.workspace_root.join("Cargo.lock");
170        let to_lock = &crate_dir.join("Cargo.lock");
171        drop(fs::copy(from_lock, to_lock));
172
173        println!("{ICON_BUILD} cargo run --bin {crate_name}");
174        let res = cargo_run_bin(&gen_manifest_path, crate_name, self.target_dir);
175
176        fs::remove_dir_all(crate_dir)?;
177
178        match res {
179            Ok(exit_status) if exit_status.success() => Ok(out_file),
180            Ok(exit_status) => Err(anyhow::anyhow!("Exit status: {}", exit_status)),
181            Err(err) => Err(err),
182        }
183    }
184}
185
186/// Get list of packages from the root package and its dependencies
187fn get_package_list(
188    metadata: &cargo_metadata::Metadata,
189    deps_level: usize,
190) -> Result<Vec<&Package>, anyhow::Error> {
191    let resolve = metadata
192        .resolve
193        .as_ref()
194        .context("failed to get resolve from metadata")?;
195    let root_package_id = resolve
196        .root
197        .as_ref()
198        .context("failed to find root package")?;
199    let node_map = resolve
200        .nodes
201        .iter()
202        .map(|n| (&n.id, n))
203        .collect::<HashMap<_, _>>();
204    let package_map = metadata
205        .packages
206        .iter()
207        .map(|p| (&p.id, p))
208        .collect::<HashMap<_, _>>();
209
210    let mut deps_set: HashSet<&PackageId> = HashSet::new();
211    deps_set.insert(root_package_id);
212
213    let mut deps = vec![root_package_id];
214    for _ in 0..deps_level {
215        deps = deps
216            .iter()
217            .filter_map(|id| node_map.get(id))
218            .flat_map(|&n| &n.dependencies)
219            .filter(|&id| metadata.workspace_members.contains(id))
220            .collect();
221        if deps.is_empty() {
222            break;
223        }
224        deps_set.extend(deps.iter());
225    }
226    let package_list: Vec<&Package> = deps_set
227        .iter()
228        .filter_map(|id| package_map.get(id))
229        .copied()
230        .collect();
231    Ok(package_list)
232}
233
234fn get_program_struct_path_from_doc(
235    program_package: &Package,
236    target_dir: &Utf8Path,
237) -> anyhow::Result<String> {
238    let program_package_file_name = program_package.name.to_lowercase().replace('-', "_");
239    println!(
240        "...running doc generation for `{}`",
241        program_package.manifest_path
242    );
243    // run `cargo doc`
244    _ = cargo_doc(&program_package.manifest_path, target_dir)?;
245    // read doc
246    let docs_path = target_dir
247        .join("doc")
248        .join(format!("{}.json", &program_package_file_name));
249    println!("...reading doc: {docs_path}");
250    let json_string = std::fs::read_to_string(docs_path)?;
251    let doc_crate: rustdoc_types::Crate = serde_json::from_str(&json_string)?;
252
253    // find `sails_rs::meta::ProgramMeta` path id
254    let program_meta_id = doc_crate
255        .paths
256        .iter()
257        .find_map(|(id, summary)| (summary.path == META_PATH).then_some(id))
258        .context("failed to find `sails_rs::meta::ProgramMeta` definition in dependencies")?;
259    // find struct implementing `sails_rs::meta::ProgramMeta`
260    let program_struct_path = doc_crate
261        .index
262        .values()
263        .find_map(|idx| try_get_trait_implementation_path(idx, program_meta_id))
264        .context("failed to find `sails_rs::meta::ProgramMeta` implementation")?;
265    let program_struct = doc_crate
266        .paths
267        .get(&program_struct_path.id)
268        .context("failed to get Program struct by id")?;
269    let program_struct_path = program_struct.path.join("::");
270    Ok(program_struct_path)
271}
272
273fn try_get_trait_implementation_path(
274    idx: &rustdoc_types::Item,
275    program_meta_id: &rustdoc_types::Id,
276) -> Option<rustdoc_types::Path> {
277    if let rustdoc_types::ItemEnum::Impl(item) = &idx.inner
278        && let Some(tp) = &item.trait_
279        && &tp.id == program_meta_id
280        && let rustdoc_types::Type::ResolvedPath(path) = &item.for_
281    {
282        return Some(path.clone());
283    }
284    None
285}
286
287fn get_idl_gen_crate_name(program_package: &Package) -> String {
288    format!("{}-idl-gen", program_package.name)
289}
290
291fn write_file<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> anyhow::Result<()> {
292    let path = path.as_ref();
293    fs::write(path, contents.as_ref())
294        .with_context(|| format!("failed to write `{}`", path.display()))
295}
296
297fn cargo_doc(
298    manifest_path: &cargo_metadata::camino::Utf8Path,
299    target_dir: &cargo_metadata::camino::Utf8Path,
300) -> anyhow::Result<ExitStatus> {
301    let cargo_path = std::env::var("CARGO").unwrap_or("cargo".into());
302
303    let mut cmd = Command::new(cargo_path);
304    cmd.env("RUSTC_BOOTSTRAP", "1")
305        .env(
306            "RUSTDOCFLAGS",
307            "-Z unstable-options --output-format=json --cap-lints=allow",
308        )
309        .env("__GEAR_WASM_BUILDER_NO_BUILD", "1")
310        .stdout(std::process::Stdio::null()) // Don't pollute output
311        .arg("doc")
312        .arg("--manifest-path")
313        .arg(manifest_path.as_str())
314        .arg("--target-dir")
315        .arg(target_dir.as_str())
316        .arg("--no-deps")
317        .arg("--quiet");
318
319    cmd.status()
320        .context("failed to execute `cargo doc` command")
321}
322
323fn cargo_run_bin(
324    manifest_path: &cargo_metadata::camino::Utf8Path,
325    bin_name: &str,
326    target_dir: &cargo_metadata::camino::Utf8Path,
327) -> anyhow::Result<ExitStatus> {
328    let cargo_path = std::env::var("CARGO").unwrap_or("cargo".into());
329
330    let mut cmd = Command::new(cargo_path);
331    cmd.env("CARGO_TARGET_DIR", target_dir)
332        .env("__GEAR_WASM_BUILDER_NO_BUILD", "1")
333        .stdout(std::process::Stdio::null()) // Don't pollute output
334        .arg("run")
335        .arg("--manifest-path")
336        .arg(manifest_path.as_str())
337        .arg("--bin")
338        .arg(bin_name);
339    cmd.status().context("failed to execute `cargo` command")
340}
341
342fn gen_cargo_toml(
343    program_package: &Package,
344    sails_package: &Package,
345    sails_features: &[String],
346) -> String {
347    let mut manifest = toml_edit::DocumentMut::new();
348    manifest["package"] = toml_edit::Item::Table(toml_edit::Table::new());
349    manifest["package"]["name"] = toml_edit::value(get_idl_gen_crate_name(program_package));
350    manifest["package"]["version"] = toml_edit::value("0.1.0");
351    manifest["package"]["edition"] = toml_edit::value(program_package.edition.as_str());
352
353    let mut dep_table = toml_edit::Table::default();
354    let mut package_table = toml_edit::InlineTable::new();
355    let manifest_dir = program_package.manifest_path.parent().unwrap();
356    package_table.insert("path", manifest_dir.as_str().into());
357    dep_table[&program_package.name] = toml_edit::value(package_table);
358
359    let sails_dep = sails_dep_v2(sails_package, sails_features);
360    dep_table[&sails_package.name] = toml_edit::value(sails_dep);
361
362    manifest["dependencies"] = toml_edit::Item::Table(dep_table);
363
364    let mut bin = toml_edit::Table::new();
365    bin["name"] = toml_edit::value(get_idl_gen_crate_name(program_package));
366    bin["path"] = toml_edit::value("src/main.rs");
367    manifest["bin"]
368        .or_insert(toml_edit::Item::ArrayOfTables(
369            toml_edit::ArrayOfTables::new(),
370        ))
371        .as_array_of_tables_mut()
372        .expect("bin is an array of tables")
373        .push(bin);
374
375    manifest["workspace"] = toml_edit::Item::Table(toml_edit::Table::new());
376
377    manifest.to_string()
378}
379
380fn sails_dep_v2(sails_package: &Package, sails_features: &[String]) -> toml_edit::InlineTable {
381    let mut features = toml_edit::Array::default();
382    features.push("idl-gen");
383    features.push("std");
384    features.extend(sails_features);
385    let mut sails_table = toml_edit::InlineTable::new();
386    let manifest_dir = sails_package.manifest_path.parent().unwrap();
387    sails_table.insert("package", sails_package.name.as_str().into());
388    sails_table.insert("path", manifest_dir.as_str().into());
389    sails_table.insert("features", features.into());
390    sails_table
391}
392
393fn gen_main_rs(
394    program_struct_path: &str,
395    program_name: &str,
396    out_file: &cargo_metadata::camino::Utf8Path,
397) -> String {
398    format!(
399        "
400fn main() {{
401    sails_rs::generate_idl_to_file::<{}>(
402        Some(r\"{}\"),
403        std::path::PathBuf::from(r\"{}\")
404    )
405    .unwrap();
406}}",
407        program_struct_path,
408        program_name,
409        out_file.as_str(),
410    )
411}