diff --git a/Cargo.lock b/Cargo.lock index a625fc09..a895fe4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -528,6 +528,7 @@ dependencies = [ "semver", "serde", "serde_json", + "shell-escape", "tempfile", "tokio", "tokio-util", @@ -2929,6 +2930,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-escape" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" + [[package]] name = "signal-hook-registry" version = "1.4.1" diff --git a/Cargo.toml b/Cargo.toml index 981ed736..37a26c77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ wit-bindgen-core = { workspace = true } wit-parser = { workspace = true } wit-component = { workspace = true } wasm-metadata = { workspace = true } +wasmparser = { workspace = true } parse_arg = { workspace = true } cargo_metadata = { workspace = true } cargo-config2 = { workspace = true } @@ -50,11 +51,11 @@ rpassword = { workspace = true } futures = { workspace = true } bytes = { workspace = true } which = { workspace = true } +shell-escape = "0.1.5" [dev-dependencies] assert_cmd = { workspace = true } predicates = { workspace = true } -wasmparser = { workspace = true } wat = { workspace = true } warg-server = { workspace = true } tempfile = { workspace = true } diff --git a/src/bindings.rs b/src/bindings.rs index 482db2b5..164dee82 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -2,7 +2,7 @@ use crate::{ last_modified_time, - metadata::{ComponentMetadata, Ownership, Target}, + metadata::{ComponentMetadata, Ownership}, registry::PackageDependencyResolution, }; use anyhow::{bail, Context, Result}; @@ -140,7 +140,13 @@ impl<'a> BindingsGenerator<'a> { /// Generates the bindings source for a package. pub fn generate(self) -> Result { - let settings = &self.resolution.metadata.section.bindings; + let settings = self + .resolution + .metadata + .section + .as_ref() + .map(|s| Cow::Borrowed(&s.bindings)) + .unwrap_or_default(); fn implementor_path_str(path: &str) -> String { format!("super::{path}") @@ -277,14 +283,10 @@ impl<'a> BindingsGenerator<'a> { import_name_map: &mut HashMap, ) -> Result<(Resolve, WorldId, Vec)> { let (mut merged, world_id, source_files) = - if let Target::Package { name, world, .. } = &resolution.metadata.section.target { - Self::target_package(resolution, name, world.as_deref())? + if let Some(name) = resolution.metadata.target_package() { + Self::target_package(resolution, name, resolution.metadata.target_world())? } else if let Some(path) = resolution.metadata.target_path() { - Self::target_local_path( - resolution, - &path, - resolution.metadata.section.target.world(), - )? + Self::target_local_path(resolution, &path, resolution.metadata.target_world())? } else { let (merged, world) = Self::target_empty_world(resolution); (merged, world, Vec::new()) diff --git a/src/commands/add.rs b/src/commands/add.rs index 43c757a8..2d055be0 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -14,6 +14,7 @@ use cargo_metadata::Package; use clap::Args; use semver::VersionReq; use std::{ + borrow::Cow, fs, path::{Path, PathBuf}, }; @@ -86,13 +87,6 @@ impl AddCommand { )?, }; - let metadata = metadata.with_context(|| { - format!( - "manifest `{path}` is not a WebAssembly component package", - path = package.manifest_path - ) - })?; - let name = match &self.name { Some(name) => name, None => &self.package.name, @@ -131,9 +125,15 @@ impl AddCommand { name: &PackageName, network_allowed: bool, ) -> Result { + let registries = metadata + .section + .as_ref() + .map(|s| Cow::Borrowed(&s.registries)) + .unwrap_or_default(); + let mut resolver = DependencyResolver::new( config.warg(), - &metadata.section.registries, + ®istries, None, config.terminal(), network_allowed, @@ -183,25 +183,39 @@ impl AddCommand { ) })?; + let metadata = document["package"]["metadata"] + .or_insert(Item::Table(Table::new())) + .as_table_mut() + .context("section `package.metadata` is not a table")?; + + metadata.set_implicit(true); + + let component = metadata["component"] + .or_insert(Item::Table(Table::new())) + .as_table_mut() + .context("section `package.metadata.component` is not a table")?; + + component.set_implicit(true); + let dependencies = if self.target { - let target = document["package"]["metadata"]["component"]["target"] + let target = component["target"] .or_insert(Item::Table(Table::new())) .as_table_mut() - .unwrap(); + .context("section `package.metadata.component.target` is not a table")?; + + target.set_implicit(true); target["dependencies"] .or_insert(Item::Table(Table::new())) .as_table_mut() - .unwrap() + .context( + "section `package.metadata.component.target.dependencies` is not a table", + )? } else { - document["package"]["metadata"]["component"]["dependencies"] + component["dependencies"] + .or_insert(Item::Table(Table::new())) .as_table_mut() - .with_context(|| { - format!( - "failed to find component metadata in manifest file `{path}`", - path = pkg.manifest_path - ) - })? + .context("section `package.metadata.component.dependencies` is not a table")? }; body(dependencies)?; @@ -254,19 +268,21 @@ impl AddCommand { } fn validate(&self, metadata: &ComponentMetadata, name: &PackageName) -> Result<()> { - if self.target { - match &metadata.section.target { - Target::Package { .. } => { - bail!("cannot add dependency `{name}` to a registry package target") - } - Target::Local { dependencies, .. } => { - if dependencies.contains_key(name) { - bail!("cannot add dependency `{name}` as it conflicts with an existing dependency"); + if let Some(section) = &metadata.section { + if self.target { + match §ion.target { + Target::Package { .. } => { + bail!("cannot add dependency `{name}` to a registry package target") + } + Target::Local { dependencies, .. } => { + if dependencies.contains_key(name) { + bail!("cannot add dependency `{name}` as it conflicts with an existing dependency"); + } } } + } else if section.dependencies.contains_key(name) { + bail!("cannot add dependency `{name}` as it conflicts with an existing dependency"); } - } else if metadata.section.dependencies.contains_key(name) { - bail!("cannot add dependency `{name}` as it conflicts with an existing dependency"); } Ok(()) diff --git a/src/commands/publish.rs b/src/commands/publish.rs index 42044b66..5395a756 100644 --- a/src/commands/publish.rs +++ b/src/commands/publish.rs @@ -6,7 +6,7 @@ use crate::{ use anyhow::{bail, Context, Result}; use cargo_component_core::{command::CommonOptions, keyring::get_signing_key, registry::find_url}; use clap::Args; -use std::path::PathBuf; +use std::{borrow::Cow, path::PathBuf}; use warg_client::RegistryUrl; use warg_crypto::signing::PrivateKey; @@ -118,15 +118,9 @@ impl PublishCommand { })?]; let package = packages[0].package; - let component_metadata = packages[0].metadata.as_ref().with_context(|| { - format!( - "package `{name}` is missing component metadata in manifest `{path}`", - name = package.name, - path = package.manifest_path - ) - })?; + let component_metadata = &packages[0].metadata; - let name = component_metadata.section.package.as_ref().with_context(|| { + let name = component_metadata.section.as_ref().map(|s| s.package.as_ref()).unwrap_or_default().with_context(|| { format!( "package `{name}` is missing a `package.metadata.component.package` setting in manifest `{path}`", name = package.name, @@ -134,9 +128,15 @@ impl PublishCommand { ) })?; + let registries = component_metadata + .section + .as_ref() + .map(|s| Cow::Borrowed(&s.registries)) + .unwrap_or_default(); + let registry_url = find_url( self.registry.as_deref(), - &component_metadata.section.registries, + ®istries, config.warg().default_url.as_deref(), )?; @@ -153,6 +153,7 @@ impl PublishCommand { let cargo_build_args = CargoArguments { color: self.common.color, verbose: self.common.verbose as usize, + help: false, quiet: self.common.quiet, targets: self.target.clone().into_iter().collect(), manifest_path: self.manifest_path.clone(), diff --git a/src/config.rs b/src/config.rs index 8f9472b7..18caa8d9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -366,6 +366,8 @@ pub struct CargoArguments { pub color: Option, /// The (count of) --verbose argument. pub verbose: usize, + /// The --help argument. + pub help: bool, /// The --quiet argument. pub quiet: bool, /// The --target argument. @@ -422,7 +424,8 @@ impl CargoArguments { .flag("--all", None) .flag("--workspace", None) .counting("--verbose", Some('v')) - .flag("--quiet", Some('q')); + .flag("--quiet", Some('q')) + .flag("--help", Some('h')); let mut iter = iter.map(Into::into).peekable(); @@ -453,6 +456,7 @@ impl CargoArguments { .map(|v| v.parse()) .transpose()?, verbose: args.get("--verbose").unwrap().count(), + help: args.get("--help").unwrap().count() > 0, quiet: args.get("--quiet").unwrap().count() > 0, manifest_path: args .get_mut("--manifest-path") @@ -775,6 +779,7 @@ mod test { CargoArguments { color: None, verbose: 0, + help: false, quiet: false, targets: Vec::new(), manifest_path: None, @@ -792,6 +797,7 @@ mod test { [ "component", "publish", + "--help", "-vvv", "--color=auto", "--manifest-path", @@ -820,6 +826,7 @@ mod test { CargoArguments { color: Some(Color::Auto), verbose: 3, + help: true, quiet: true, targets: vec!["foo".to_string(), "bar".to_string()], manifest_path: Some("Cargo.toml".into()), diff --git a/src/lib.rs b/src/lib.rs index eac1761e..b85a113d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,17 +12,20 @@ use cargo_component_core::{ terminal::Colors, }; use cargo_config2::{PathAndArgs, TargetTripleRef}; -use cargo_metadata::{Message, Metadata, MetadataCommand, Package}; +use cargo_metadata::{Artifact, Message, Metadata, MetadataCommand, Package}; use config::{CargoArguments, CargoPackageSpec, Config}; use lock::{acquire_lock_file_ro, acquire_lock_file_rw}; use metadata::ComponentMetadata; use registry::{PackageDependencyResolution, PackageResolutionMap}; use semver::Version; +use shell_escape::escape; use std::{ borrow::Cow, collections::HashMap, - env, fs, - io::{BufRead, BufReader}, + env, + fmt::{self, Write}, + fs::{self, File}, + io::{BufRead, BufReader, Read, Seek, SeekFrom}, path::{Path, PathBuf}, process::{Command, Stdio}, time::{Duration, SystemTime}, @@ -31,6 +34,7 @@ use warg_client::storage::{ContentStorage, PublishEntry, PublishInfo}; use warg_crypto::signing::PrivateKey; use warg_protocol::registry::PackageName; use wasm_metadata::{Link, LinkType, RegistryMetadata}; +use wasmparser::{Parser, Payload}; use wit_component::ComponentEncoder; mod bindings; @@ -47,13 +51,12 @@ fn is_wasm_target(target: &str) -> bool { } /// Represents a cargo package paired with its component metadata. +#[derive(Debug)] pub struct PackageComponentMetadata<'a> { - /// The associated package. + /// The cargo package. pub package: &'a Package, /// The associated component metadata. - /// - /// This is `None` if the package is not a component. - pub metadata: Option, + pub metadata: ComponentMetadata, } impl<'a> PackageComponentMetadata<'a> { @@ -66,21 +69,61 @@ impl<'a> PackageComponentMetadata<'a> { } } -/// Represents a cargo build artifact. -struct BuildArtifact { - /// The path to the artifact. - path: PathBuf, - /// The package that this artifact was compiled for. - package: String, - /// The target that this artifact was compiled for. - target: String, - /// Whether or not this artifact was `fresh` during this build. - fresh: bool, +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +enum CargoCommand { + #[default] + Other, + Help, + Build, + Run, + Test, + Bench, +} + +impl CargoCommand { + fn buildable(self) -> bool { + matches!(self, Self::Build | Self::Run | Self::Test | Self::Bench) + } + + fn runnable(self) -> bool { + matches!(self, Self::Run | Self::Test | Self::Bench) + } + + fn testable(self) -> bool { + matches!(self, Self::Test | Self::Bench) + } +} + +impl fmt::Display for CargoCommand { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Help => write!(f, "help"), + Self::Build => write!(f, "build"), + Self::Run => write!(f, "run"), + Self::Test => write!(f, "test"), + Self::Bench => write!(f, "bench"), + Self::Other => write!(f, ""), + } + } +} + +impl From<&str> for CargoCommand { + fn from(s: &str) -> Self { + match s { + "h" | "help" => Self::Help, + "b" | "build" | "rustc" => Self::Build, + "r" | "run" => Self::Run, + "t" | "test" => Self::Test, + "bench" => Self::Bench, + _ => Self::Other, + } + } } /// Runs the cargo command as specified in the configuration. /// -/// Note: if the command returns a non-zero status, this +/// Note: if the command returns a non-zero status, or if the +/// `--help` option was given on the command line, this /// function will exit the process. /// /// Returns any relevant output components. @@ -92,20 +135,23 @@ pub async fn run_cargo_command( cargo_args: &CargoArguments, spawn_args: &[String], ) -> Result> { - let mut import_name_map = generate_bindings(config, metadata, packages, cargo_args).await?; + let import_name_map = generate_bindings(config, metadata, packages, cargo_args).await?; - let cargo = std::env::var("CARGO") + let cargo_path = std::env::var("CARGO") .map(PathBuf::from) .ok() .unwrap_or_else(|| PathBuf::from("cargo")); - let is_build = matches!(subcommand, Some("b") | Some("build") | Some("rustc")); - let is_run = matches!(subcommand, Some("r") | Some("run")); - let is_test = matches!(subcommand, Some("t") | Some("test") | Some("bench")); + let command = if cargo_args.help { + // Treat `--help` as the help command + CargoCommand::Help + } else { + subcommand.map(CargoCommand::from).unwrap_or_default() + }; - let (build_args, runtime_args) = match spawn_args.iter().position(|a| a == "--") { + let (build_args, output_args) = match spawn_args.iter().position(|a| a == "--") { Some(position) => spawn_args.split_at(position), - None => (spawn_args, &[] as &[String]), + None => (spawn_args, &[] as _), }; let needs_runner = !build_args.iter().any(|a| a == "--no-run"); @@ -118,31 +164,32 @@ pub async fn run_cargo_command( // Spawn the actual cargo command log::debug!( - "spawning cargo `{cargo}` with arguments `{args:?}`", - cargo = cargo.display(), + "spawning cargo `{path}` with arguments `{args:?}`", + path = cargo_path.display(), args = args.clone().collect::>(), ); - let mut cmd = Command::new(&cargo); - if is_run { - cmd.arg("build"); + let mut cargo = Command::new(&cargo_path); + if command == CargoCommand::Run { + // Treat a run as a build command as we need to componentize the output + cargo.arg("build"); if let Some(arg) = args.peek() { if Some((*arg).as_str()) == subcommand { args.next().unwrap(); } } } - cmd.args(args); + cargo.args(args); // TODO: consider targets from .cargo/config.toml - // Handle the target for build, run and test commands - if is_build || is_run || is_test { + // Handle the target for buildable commands + if command.buildable() { install_wasm32_wasi(config)?; // Add an implicit wasm32-wasi target if there isn't a wasm target present if !cargo_args.targets.iter().any(|t| is_wasm_target(t)) { - cmd.arg("--target").arg("wasm32-wasi"); + cargo.arg("--target").arg("wasm32-wasi"); } if let Some(format) = &cargo_args.message_format { @@ -153,74 +200,119 @@ pub async fn run_cargo_command( // It will output the message as json so we can extract the wasm files // that will be componentized - cmd.arg("--message-format").arg("json-render-diagnostics"); - cmd.stdout(Stdio::piped()); + cargo.arg("--message-format").arg("json-render-diagnostics"); + cargo.stdout(Stdio::piped()); } else { - cmd.stdout(Stdio::inherit()); + cargo.stdout(Stdio::inherit()); + } + + // At this point, spawn the command for help and terminate + if command == CargoCommand::Help { + let mut child = cargo.spawn().context(format!( + "failed to spawn `{path}`", + path = cargo_path.display() + ))?; + + let status = child.wait().context(format!( + "failed to wait for `{path}` to finish", + path = cargo_path.display() + ))?; + + std::process::exit(status.code().unwrap_or(0)); } - if needs_runner && is_test { + if needs_runner && command.testable() { // Only build for the test target; running will be handled // after the componentization - cmd.arg("--no-run"); + cargo.arg("--no-run"); } - let mut runner: Option = None; - if needs_runner && (is_run || is_test) { - let cargo_config = cargo_config2::Config::load()?; - - // We check here before we actually build that a runtime is present. - // We first check the runner for `wasm32-wasi` in the order from - // cargo's convention for a user-supplied runtime (path or executable) - // and use the default, namely `wasmtime`, if it is not set. - let (r, using_default) = cargo_config - .runner(TargetTripleRef::from("wasm32-wasi")) - .unwrap_or_default() - .map(|runner_override| (runner_override, false)) - .unwrap_or_else(|| { - ( - PathAndArgs::new("wasmtime") - .args(vec!["-S", "preview2", "-S", "common"]) - .to_owned(), - true, - ) - }); - runner = Some(r.clone()); - - // Treat the runner object as an executable with list of arguments it - // that was extracted by splitting each whitespace. This allows the user - // to provide arguments which are passed to wasmtime without having to - // add more command-line argument parsing to this crate. - let wasi_runner = r.path.to_string_lossy().into_owned(); - - if !using_default { - // check if the override runner exists - if !(r.path.exists() || which::which(r.path).is_ok()) { - bail!( - "failed to find `{wasi_runner}` specified by either the `CARGO_TARGET_WASM32_WASI_RUNNER`\ - environment variable or as the `wasm32-wasi` runner in `.cargo/config.toml`" - ); - } - } else if which::which(r.path).is_err() { + let runner = if needs_runner && command.runnable() { + Some(get_runner()?) + } else { + None + }; + + let artifacts = spawn_cargo(cargo, &cargo_path, cargo_args, command.buildable())?; + + let outputs = componentize_artifacts( + config, + metadata, + &artifacts, + packages, + &import_name_map, + command, + output_args, + )?; + + if let Some(runner) = runner { + spawn_outputs(config, &runner, output_args, &outputs, command)?; + } + + Ok(outputs.into_iter().map(|o| o.path).collect()) +} + +fn get_runner() -> Result { + let cargo_config = cargo_config2::Config::load()?; + + // We check here before we actually build that a runtime is present. + // We first check the runner for `wasm32-wasi` in the order from + // cargo's convention for a user-supplied runtime (path or executable) + // and use the default, namely `wasmtime`, if it is not set. + let (runner, using_default) = cargo_config + .runner(TargetTripleRef::from("wasm32-wasi")) + .unwrap_or_default() + .map(|runner_override| (runner_override, false)) + .unwrap_or_else(|| { + ( + PathAndArgs::new("wasmtime") + .args(vec!["-S", "preview2", "-S", "common"]) + .to_owned(), + true, + ) + }); + + // Treat the runner object as an executable with list of arguments it + // that was extracted by splitting each whitespace. This allows the user + // to provide arguments which are passed to wasmtime without having to + // add more command-line argument parsing to this crate. + let wasi_runner = runner.path.to_string_lossy().into_owned(); + + if !using_default { + // check if the override runner exists + if !(runner.path.exists() || which::which(&runner.path).is_ok()) { bail!( - "failed to find `{wasi_runner}` on PATH\n\n\ - ensure Wasmtime is installed before running this command\n\n\ - {msg}:\n\n {instructions}", - msg = if cfg!(unix) { - "Wasmtime can be installed via a shell script" - } else { - "Wasmtime can be installed via the GitHub releases page" - }, - instructions = if cfg!(unix) { - "curl https://wasmtime.dev/install.sh -sSf | bash" - } else { - "https://github.com/bytecodealliance/wasmtime/releases" - }, + "failed to find `{wasi_runner}` specified by either the `CARGO_TARGET_WASM32_WASI_RUNNER`\ + environment variable or as the `wasm32-wasi` runner in `.cargo/config.toml`" ); } + } else if which::which(&runner.path).is_err() { + bail!( + "failed to find `{wasi_runner}` on PATH\n\n\ + ensure Wasmtime is installed before running this command\n\n\ + {msg}:\n\n {instructions}", + msg = if cfg!(unix) { + "Wasmtime can be installed via a shell script" + } else { + "Wasmtime can be installed via the GitHub releases page" + }, + instructions = if cfg!(unix) { + "curl https://wasmtime.dev/install.sh -sSf | bash" + } else { + "https://github.com/bytecodealliance/wasmtime/releases" + }, + ); } - let mut outputs = Vec::new(); + Ok(runner) +} + +fn spawn_cargo( + mut cmd: Command, + cargo: &Path, + cargo_args: &CargoArguments, + process_messages: bool, +) -> Result> { log::debug!("spawning command {:?}", cmd); let mut child = cmd.spawn().context(format!( @@ -229,8 +321,7 @@ pub async fn run_cargo_command( ))?; let mut artifacts = Vec::new(); - - if is_build || is_run || is_test { + if process_messages { let stdout = child.stdout.take().expect("no stdout"); let reader = BufReader::new(stdout); for line in reader.lines() { @@ -249,19 +340,13 @@ pub async fn run_cargo_command( if let Message::CompilerArtifact(artifact) = message.context("unexpected JSON message from cargo")? { - for path in artifact.filenames { - let path = PathBuf::from(path); - if path.extension().and_then(|s| s.to_str()) == Some("wasm") { - log::debug!( - "found WebAssembly build artifact `{path}`", - path = path.display() - ); - artifacts.push(BuildArtifact { - path, - package: artifact.package_id.to_string(), - target: artifact.target.name.clone(), - fresh: artifact.fresh, - }); + for path in &artifact.filenames { + match path.extension() { + Some("wasm") => { + artifacts.push(artifact); + break; + } + _ => continue, } } } @@ -278,86 +363,186 @@ pub async fn run_cargo_command( std::process::exit(status.code().unwrap_or(1)); } - for artifact in &artifacts { - if artifact.path.exists() { - for PackageComponentMetadata { package, metadata } in packages { - // When passing `--bin` the target name will be the binary being executed, - // but the package id still points to the package the binary is part of. - if artifact.target == package.name || artifact.package.starts_with(&package.name) { - if let Some(metadata) = &metadata { - let is_bin = is_test || package.targets.iter().any(|t| t.is_bin()); - let bytes = &mut fs::read(&artifact.path).with_context(|| { - format!( - "failed to read output module `{path}`", - path = artifact.path.display() - ) - })?; - - // If the compilation output is not a WebAssembly module, then do nothing - // Note: due to the way cargo currently works on macOS, it will overwrite - // a previously generated component on an up-to-date build. - // - // Thus we always componentize the artifact on macOS, but we only print - // the status message if the artifact was not fresh. - // - // See: https://github.com/rust-lang/cargo/blob/99ad42deb4b0be0cdb062d333d5e63460a94c33c/crates/cargo-util/src/paths.rs#L542-L550 - if bytes.len() < 8 || bytes[0..4] != [0x0, b'a', b's', b'm'] { - bail!( - "expected `{path}` to be a WebAssembly module or component", - path = artifact.path.display() - ); - } + Ok(artifacts) +} - // Check for the module header version - if bytes[4..8] == [0x01, 0x00, 0x00, 0x00] { - create_component( - config, - metadata, - import_name_map - .remove(&package.name) - .expect("package already processed"), - &artifact.path, - is_bin, - artifact.fresh, - )?; - } else { - log::debug!( - "output file `{path}` is already a WebAssembly component", - path = artifact.path.display() - ); - } - } +struct Output { + /// The path to the output. + path: PathBuf, + /// The display name if the output is an executable. + display: Option, +} + +fn componentize_artifacts( + config: &Config, + cargo_metadata: &Metadata, + artifacts: &[Artifact], + packages: &[PackageComponentMetadata<'_>], + import_name_map: &HashMap>, + command: CargoCommand, + output_args: &[String], +) -> Result> { + let mut outputs = Vec::new(); + let cwd = + env::current_dir().with_context(|| "couldn't get the current directory of the process")?; + + for artifact in artifacts { + for path in artifact + .filenames + .iter() + .filter(|p| p.extension() == Some("wasm") && p.exists()) + { + let (package, metadata) = match packages + .iter() + .find(|p| p.package.id == artifact.package_id) + { + Some(PackageComponentMetadata { package, metadata }) => (package, metadata), + _ => continue, + }; - outputs.push(artifact.path.clone()); + match read_artifact(path.as_std_path(), metadata.section.is_some())? { + ArtifactKind::Module => { + log::debug!( + "output file `{path}` is a WebAssembly module that will not be componentized" + ); + continue; + } + ArtifactKind::Componentizable(bytes) => { + componentize( + config, + metadata, + import_name_map + .get(&package.name) + .expect("package already processed"), + artifact, + path.as_std_path(), + &cwd, + &bytes, + )?; + } + ArtifactKind::Component => { + log::debug!("output file `{path}` is already a WebAssembly component"); + } + ArtifactKind::Other => { + log::debug!("output file `{path}` is not a WebAssembly module or component"); + continue; } } + + let mut output = Output { + path: path.as_std_path().into(), + display: None, + }; + + if command.testable() && artifact.profile.test + || (command == CargoCommand::Run && !artifact.profile.test) + { + output.display = Some(output_display_name( + cargo_metadata, + artifact, + path.as_std_path(), + &cwd, + command, + output_args, + )); + } + + outputs.push(output); } } - for PackageComponentMetadata { - package, - metadata: _, - } in packages - { - if !artifacts.iter().any( - |BuildArtifact { - package: output, .. - }| output.starts_with(&package.name), - ) { - log::warn!( - "no build output found for package `{name}`", - name = package.name - ); + Ok(outputs) +} + +fn output_display_name( + metadata: &Metadata, + artifact: &Artifact, + path: &Path, + cwd: &Path, + command: CargoCommand, + output_args: &[String], +) -> String { + // The format of the display name is intentionally the same + // as what `cargo` formats for running executables. + let test_path = &artifact.target.src_path; + let short_test_path = test_path + .strip_prefix(&metadata.workspace_root) + .unwrap_or(test_path); + + if artifact.target.is_test() || artifact.target.is_bench() { + format!( + "{short_test_path} ({path})", + path = path.strip_prefix(cwd).unwrap_or(path).display() + ) + } else if command == CargoCommand::Test { + format!( + "unittests {short_test_path} ({path})", + path = path.strip_prefix(cwd).unwrap_or(path).display() + ) + } else if command == CargoCommand::Bench { + format!( + "benches {short_test_path} ({path})", + path = path.strip_prefix(cwd).unwrap_or(path).display() + ) + } else { + let mut s = String::new(); + write!(&mut s, "`").unwrap(); + + write!( + &mut s, + "{}", + path.strip_prefix(cwd).unwrap_or(path).display() + ) + .unwrap(); + + for arg in output_args.iter().skip(1) { + write!(&mut s, " {}", escape(arg.into())).unwrap(); } + + write!(&mut s, "`").unwrap(); + s } +} + +fn spawn_outputs( + config: &Config, + runner: &PathAndArgs, + output_args: &[String], + outputs: &[Output], + command: CargoCommand, +) -> Result<()> { + let executables = outputs + .iter() + .filter_map(|output| { + output + .display + .as_ref() + .map(|display| (display, &output.path)) + }) + .collect::>(); + + if command == CargoCommand::Run && executables.len() > 1 { + config.terminal().error( + "`cargo component run` can run at most one component, but multiple were specified", + ) + } else if executables.is_empty() { + config.terminal().error(format!( + "a component {ty} target must be available for `cargo component {command}`", + ty = if command == CargoCommand::Run { + "bin" + } else { + "test" + } + )) + } else { + for (display, executable) in executables { + config.terminal().status("Running", display)?; - if let Some(runner) = runner { - for run in outputs.iter() { let mut cmd = Command::new(&runner.path); cmd.args(&runner.args) .arg("--") - .arg(run) - .args(runtime_args.iter().skip(1)) + .arg(executable) + .args(output_args.iter().skip(1)) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); log::debug!("spawning command {:?}", cmd); @@ -376,9 +561,78 @@ pub async fn run_cargo_command( std::process::exit(status.code().unwrap_or(1)); } } + + Ok(()) } +} - Ok(outputs) +enum ArtifactKind { + /// A WebAssembly module that will not be componentized. + Module, + /// A WebAssembly module that will be componentized. + Componentizable(Vec), + /// A WebAssembly component. + Component, + /// An artifact that is not a WebAssembly module or component. + Other, +} + +fn read_artifact(path: &Path, mut componentizable: bool) -> Result { + let mut file = File::open(path).with_context(|| { + format!( + "failed to open build output `{path}`", + path = path.display() + ) + })?; + + let mut header = [0; 8]; + if file.read_exact(&mut header).is_err() { + return Ok(ArtifactKind::Other); + } + + if Parser::is_core_wasm(&header) { + file.seek(SeekFrom::Start(0)).with_context(|| { + format!( + "failed to seek to the start of `{path}`", + path = path.display() + ) + })?; + + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).with_context(|| { + format!( + "failed to read output WebAssembly module `{path}`", + path = path.display() + ) + })?; + + if !componentizable { + let parser = Parser::new(0); + for payload in parser.parse_all(&bytes) { + if let Payload::CustomSection(reader) = payload.with_context(|| { + format!( + "failed to parse output WebAssembly module `{path}`", + path = path.display() + ) + })? { + if reader.name().starts_with("component-type") { + componentizable = true; + break; + } + } + } + } + + if componentizable { + Ok(ArtifactKind::Componentizable(bytes)) + } else { + Ok(ArtifactKind::Module) + } + } else if Parser::is_component(&header) { + Ok(ArtifactKind::Component) + } else { + Ok(ArtifactKind::Other) + } } fn last_modified_time(path: &Path) -> Result { @@ -475,19 +729,18 @@ async fn generate_bindings( }) .transpose()?; + let cwd = + env::current_dir().with_context(|| "couldn't get the current directory of the process")?; + let resolver = lock_file.as_ref().map(LockFileResolver::new); let resolution_map = create_resolution_map(config, packages, resolver, cargo_args.network_allowed()).await?; let mut import_name_map = HashMap::new(); for PackageComponentMetadata { package, .. } in packages { - let resolution = match resolution_map.get(&package.id) { - Some(resolution) => resolution, - None => continue, - }; - + let resolution = resolution_map.get(&package.id).expect("missing resolution"); import_name_map.insert( package.name.clone(), - generate_package_bindings(config, resolution, last_modified_exe).await?, + generate_package_bindings(config, resolution, last_modified_exe, &cwd).await?, ); } @@ -525,15 +778,10 @@ async fn create_resolution_map<'a>( let mut map = PackageResolutionMap::default(); for PackageComponentMetadata { package, metadata } in packages { - match metadata { - Some(metadata) => { - let resolution = - PackageDependencyResolution::new(config, metadata, lock_file, network_allowed) - .await?; - map.insert(package.id.clone(), resolution); - } - None => continue, - } + let resolution = + PackageDependencyResolution::new(config, metadata, lock_file, network_allowed).await?; + + map.insert(package.id.clone(), resolution); } Ok(map) @@ -543,6 +791,7 @@ async fn generate_package_bindings( config: &Config, resolution: &PackageDependencyResolution<'_>, last_modified_exe: SystemTime, + cwd: &Path, ) -> Result> { // TODO: make the output path configurable let output_dir = resolution @@ -562,7 +811,7 @@ async fn generate_package_bindings( let (generator, import_name_map) = BindingsGenerator::new(resolution)?; match generator.reason(last_modified_exe, last_modified_output)? { Some(reason) => { - ::log::debug!( + log::debug!( "generating bindings for package `{name}` at `{path}` because {reason}", name = resolution.metadata.name, path = bindings_path.display(), @@ -573,7 +822,10 @@ async fn generate_package_bindings( format!( "bindings for {name} ({path})", name = resolution.metadata.name, - path = bindings_path.display() + path = bindings_path + .strip_prefix(cwd) + .unwrap_or(&bindings_path) + .display() ), )?; @@ -593,7 +845,7 @@ async fn generate_package_bindings( })?; } None => { - ::log::debug!( + log::debug!( "existing bindings for package `{name}` at `{path}` is up-to-date", name = resolution.metadata.name, path = bindings_path.display(), @@ -604,13 +856,19 @@ async fn generate_package_bindings( Ok(import_name_map) } -fn adapter_bytes<'a>( +fn adapter_bytes( config: &Config, - metadata: &'a ComponentMetadata, - binary: bool, -) -> Result> { - if let Some(adapter) = &metadata.section.adapter { - if metadata.section.proxy { + metadata: &ComponentMetadata, + is_command: bool, +) -> Result> { + let (adapter, proxy) = if let Some(section) = &metadata.section { + (section.adapter.as_deref(), section.proxy) + } else { + (None, false) + }; + + if let Some(adapter) = adapter { + if proxy { config.terminal().warn( "ignoring `proxy` setting due to `adapter` setting being present in `Cargo.toml`", )?; @@ -626,8 +884,8 @@ fn adapter_bytes<'a>( .into()); } - if binary { - if metadata.section.proxy { + if is_command { + if proxy { config .terminal() .warn("ignoring `proxy` setting in `Cargo.toml` for command component")?; @@ -638,7 +896,7 @@ fn adapter_bytes<'a>( env!("WASI_ADAPTER_VERSION"), "/wasi_snapshot_preview1.command.wasm" )))) - } else if metadata.section.proxy { + } else if proxy { Ok(Cow::Borrowed(include_bytes!(concat!( "../adapters/", env!("WASI_ADAPTER_VERSION"), @@ -653,46 +911,56 @@ fn adapter_bytes<'a>( } } -fn create_component( +fn componentize( config: &Config, metadata: &ComponentMetadata, - import_name_map: HashMap, + import_name_map: &HashMap, + artifact: &Artifact, path: &Path, - binary: bool, - fresh: bool, + cwd: &Path, + bytes: &[u8], ) -> Result<()> { - ::log::debug!( + let is_command = + artifact.profile.test || artifact.target.crate_types.iter().any(|t| t == "bin"); + + log::debug!( "componentizing WebAssembly module `{path}` as a {kind} component (fresh = {fresh})", path = path.display(), - kind = if binary { "command" } else { "reactor" }, + kind = if is_command { "command" } else { "reactor" }, + fresh = artifact.fresh, ); - let module = fs::read(path).with_context(|| { - format!( - "failed to read output module `{path}`", - path = path.display() - ) - })?; - // Only print the message if the artifact was not fresh - if !fresh { + // Due to the way cargo currently works on macOS, it will overwrite + // a previously generated component on an up-to-date build. + // + // Therefore, we always componentize the artifact on macOS, but we + // only print the status message if the artifact was not fresh. + // + // See: https://github.com/rust-lang/cargo/blob/99ad42deb4b0be0cdb062d333d5e63460a94c33c/crates/cargo-util/src/paths.rs#L542-L550 + if !artifact.fresh { config.terminal().status( "Creating", - format!("component {path}", path = path.display()), + format!( + "component {path}", + path = path.strip_prefix(cwd).unwrap_or(path).display() + ), )?; } let encoder = ComponentEncoder::default() - .module(&module)? - .import_name_map(import_name_map) + .module(bytes)? + .import_name_map(import_name_map.clone()) .adapter( "wasi_snapshot_preview1", - &adapter_bytes(config, metadata, binary)?, + &adapter_bytes(config, metadata, is_command)?, ) .with_context(|| { format!( "failed to load adapter module `{path}`", - path = if let Some(path) = &metadata.section.adapter { + path = if let Some(path) = + metadata.section.as_ref().and_then(|s| s.adapter.as_ref()) + { path.as_path() } else { Path::new("") diff --git a/src/metadata.rs b/src/metadata.rs index f9860b78..14d231e0 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -301,32 +301,31 @@ pub struct ComponentMetadata { /// The last modified time of the manifest file. pub modified_at: SystemTime, /// The component section in `Cargo.toml`. - pub section: ComponentSection, + pub section: Option, } impl ComponentMetadata { /// Creates a new component metadata for the given cargo package. - /// - /// Returns `Ok(None)` if the package does not have a `component` section. - pub fn from_package(package: &Package) -> Result> { + pub fn from_package(package: &Package) -> Result { log::debug!( "searching for component metadata in manifest `{path}`", path = package.manifest_path ); - let mut section: ComponentSection = match package.metadata.get("component").cloned() { - Some(component) => from_value(component).with_context(|| { + let mut section: Option = match package.metadata.get("component").cloned() + { + Some(component) => Some(from_value(component).with_context(|| { format!( "failed to deserialize component metadata from `{path}`", path = package.manifest_path ) - })?, + })?), None => { log::debug!( "manifest `{path}` has no component metadata", path = package.manifest_path ); - return Ok(None); + None } }; @@ -343,38 +342,53 @@ impl ComponentMetadata { let modified_at = crate::last_modified_time(package.manifest_path.as_std_path())?; // Make all paths stored in the metadata relative to the manifest directory. - if let Target::Local { - path, dependencies, .. - } = &mut section.target - { - if let Some(path) = path { - *path = manifest_dir.join(path.as_path()); + if let Some(section) = &mut section { + if let Target::Local { + path, dependencies, .. + } = &mut section.target + { + if let Some(path) = path { + *path = manifest_dir.join(path.as_path()); + } + + for dependency in dependencies.values_mut() { + if let Dependency::Local(path) = dependency { + *path = manifest_dir.join(path.as_path()); + } + } } - for dependency in dependencies.values_mut() { + for dependency in section.dependencies.values_mut() { if let Dependency::Local(path) = dependency { *path = manifest_dir.join(path.as_path()); } } - } - for dependency in section.dependencies.values_mut() { - if let Dependency::Local(path) = dependency { - *path = manifest_dir.join(path.as_path()); + if let Some(adapter) = section.adapter.as_mut() { + *adapter = manifest_dir.join(adapter.as_path()); } } - if let Some(adapter) = section.adapter.as_mut() { - *adapter = manifest_dir.join(adapter.as_path()); - } - - Ok(Some(Self { + Ok(Self { name: package.name.clone(), version: package.version.clone(), manifest_path: package.manifest_path.clone().into(), modified_at, section, - })) + }) + } + + /// Gets the target package name. + /// + /// Returns `None` if the target is not a registry package. + pub fn target_package(&self) -> Option<&PackageName> { + match &self.section { + Some(ComponentSection { + target: Target::Package { name, .. }, + .. + }) => Some(name), + _ => None, + } } /// Gets the path to a local target. @@ -382,11 +396,11 @@ impl ComponentMetadata { /// Returns `None` if the target is a registry package or /// if a path is not specified and the default path does not exist. pub fn target_path(&self) -> Option> { - match &self.section.target { - Target::Local { + match self.section.as_ref().map(|s| &s.target) { + Some(Target::Local { path: Some(path), .. - } => Some(path.into()), - Target::Local { path: None, .. } => { + }) => Some(path.into()), + None | Some(Target::Local { path: None, .. }) => { let path = self.manifest_path.parent().unwrap().join(DEFAULT_WIT_DIR); if path.exists() { @@ -395,7 +409,14 @@ impl ComponentMetadata { None } } - Target::Package { .. } => None, + Some(Target::Package { .. }) => None, } } + + /// Gets the target world. + /// + /// Returns `None` if there is no target world. + pub fn target_world(&self) -> Option<&str> { + self.section.as_ref().and_then(|s| s.target.world()) + } } diff --git a/src/registry.rs b/src/registry.rs index f433bc2d..1413866b 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -6,6 +6,7 @@ use cargo_component_core::{ lock::{LockFile, LockFileResolver, LockedPackage, LockedPackageVersion}, registry::{DependencyResolution, DependencyResolutionMap, DependencyResolver}, }; +use cargo_metadata::PackageId; use semver::Version; use std::collections::HashMap; use warg_crypto::hash::AnyHash; @@ -58,21 +59,26 @@ impl<'a> PackageDependencyResolution<'a> { lock_file: Option>, network_allowed: bool, ) -> Result { - let target_deps = metadata.section.target.dependencies(); - - let mut resolver = DependencyResolver::new( - config.warg(), - &metadata.section.registries, - lock_file, - config.terminal(), - network_allowed, - )?; - - for (name, dependency) in target_deps.iter() { - resolver.add_dependency(name, dependency).await?; - } + match &metadata.section { + Some(section) => { + let target_deps = section.target.dependencies(); + + let mut resolver = DependencyResolver::new( + config.warg(), + §ion.registries, + lock_file, + config.terminal(), + network_allowed, + )?; + + for (name, dependency) in target_deps.iter() { + resolver.add_dependency(name, dependency).await?; + } - resolver.resolve().await + resolver.resolve().await + } + None => Ok(Default::default()), + } } async fn resolve_deps( @@ -81,27 +87,30 @@ impl<'a> PackageDependencyResolution<'a> { lock_file: Option>, network_allowed: bool, ) -> Result { - let mut resolver = DependencyResolver::new( - config.warg(), - &metadata.section.registries, - lock_file, - config.terminal(), - network_allowed, - )?; - - for (name, dependency) in &metadata.section.dependencies { - resolver.add_dependency(name, dependency).await?; - } + match &metadata.section { + Some(section) => { + let mut resolver = DependencyResolver::new( + config.warg(), + §ion.registries, + lock_file, + config.terminal(), + network_allowed, + )?; + + for (name, dependency) in §ion.dependencies { + resolver.add_dependency(name, dependency).await?; + } - resolver.resolve().await + resolver.resolve().await + } + None => Ok(Default::default()), + } } } /// Represents a mapping between all component packages and their dependency resolutions. #[derive(Debug, Default, Clone)] -pub struct PackageResolutionMap<'a>( - HashMap>, -); +pub struct PackageResolutionMap<'a>(HashMap>); impl<'a> PackageResolutionMap<'a> { /// Inserts a package dependency resolution into the map. @@ -109,11 +118,7 @@ impl<'a> PackageResolutionMap<'a> { /// # Panics /// /// Panics if the package already has a dependency resolution. - pub fn insert( - &mut self, - id: cargo_metadata::PackageId, - resolution: PackageDependencyResolution<'a>, - ) { + pub fn insert(&mut self, id: PackageId, resolution: PackageDependencyResolution<'a>) { let prev = self.0.insert(id, resolution); assert!(prev.is_none()); } @@ -121,7 +126,7 @@ impl<'a> PackageResolutionMap<'a> { /// Gets a package dependency resolution from the map. /// /// Returns `None` if the package has no dependency resolution. - pub fn get(&self, id: &cargo_metadata::PackageId) -> Option<&PackageDependencyResolution<'a>> { + pub fn get(&self, id: &PackageId) -> Option<&PackageDependencyResolution<'a>> { self.0.get(id) }