diff options
| author | Henry <mail@henrygressmann.de> | 2026-04-26 19:44:34 +0200 |
|---|---|---|
| committer | Henry <mail@henrygressmann.de> | 2026-04-26 19:44:34 +0200 |
| commit | 463a3a6f56a5ca06cda890fd6fe7f16485ef70a0 (patch) | |
| tree | bfa657a2de16c04cd36f4b6c61e77b370fcd46fc /crates/cli | |
| parent | 27004a8738c7a3c32ac4fb6966f32647b4c96db7 (diff) | |
feat: new tinywasm cli
Signed-off-by: Henry <mail@henrygressmann.de>
Diffstat (limited to 'crates/cli')
| -rw-r--r-- | crates/cli/Cargo.toml | 29 | ||||
| -rw-r--r-- | crates/cli/README.md | 27 | ||||
| -rw-r--r-- | crates/cli/src/args.rs | 37 | ||||
| -rw-r--r-- | crates/cli/src/bin.rs | 119 | ||||
| -rw-r--r-- | crates/cli/src/cli.rs | 128 | ||||
| -rw-r--r-- | crates/cli/src/cmd/compile.rs | 15 | ||||
| -rw-r--r-- | crates/cli/src/cmd/completion.rs | 12 | ||||
| -rw-r--r-- | crates/cli/src/cmd/dump.rs | 71 | ||||
| -rw-r--r-- | crates/cli/src/cmd/inspect.rs | 35 | ||||
| -rw-r--r-- | crates/cli/src/cmd/mod.rs | 7 | ||||
| -rw-r--r-- | crates/cli/src/cmd/run.rs | 47 | ||||
| -rw-r--r-- | crates/cli/src/cmd/wast.rs | 12 | ||||
| -rw-r--r-- | crates/cli/src/engine_flags.rs | 154 | ||||
| -rw-r--r-- | crates/cli/src/lib.rs | 34 | ||||
| -rw-r--r-- | crates/cli/src/load.rs | 103 | ||||
| -rw-r--r-- | crates/cli/src/output.rs | 85 | ||||
| -rw-r--r-- | crates/cli/src/util.rs | 1 | ||||
| -rw-r--r-- | crates/cli/src/value_parse.rs | 55 | ||||
| -rw-r--r-- | crates/cli/src/wast_runner.rs | 885 | ||||
| -rw-r--r-- | crates/cli/src/wat.rs | 10 | ||||
| -rw-r--r-- | crates/cli/tests/cli.rs | 137 |
21 files changed, 1829 insertions, 174 deletions
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 8472afb..add21d3 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,30 +1,45 @@ [package] name="tinywasm-cli" version.workspace=true -description="Command-line interface for TinyWasm" +description="Minimal command-line interface for TinyWasm" edition.workspace=true license.workspace=true authors.workspace=true repository.workspace=true +documentation="https://docs.rs/tinywasm-cli" rust-version.workspace=true -keywords.workspace=true -categories=["wasm"] +keywords=["tinywasm", "wasm", "webassembly", "cli", "runtime"] +categories=["command-line-utilities", "wasm"] readme="README.md" +[lib] +name="tinywasm_cli" +path="src/lib.rs" + [[bin]] -name="tinywasm-cli" +name="tinywasm" path="src/bin.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +clap={version="4.5", features=["derive"]} +clap_complete="4.5" eyre.workspace=true log.workspace=true pretty_env_logger.workspace=true tinywasm={version="0.9.0-alpha.0", path="../tinywasm", features=["std", "parser"]} -argh="0.1" +wat={workspace=true, optional=true} wast={workspace=true, optional=true} +owo-colors={workspace=true} +anstream={version="1.0"} [features] -default=["wat"] -wat=["dep:wast"] +default=["wat", "wast"] +wat=["dep:wat"] +wast=["dep:wast"] + +[dev-dependencies] +assert_cmd="2.0" +predicates="3.1" +tempfile="3.20" diff --git a/crates/cli/README.md b/crates/cli/README.md index aa4d0c8..88172c9 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -1,13 +1,30 @@ # `tinywasm-cli` -The `tinywasm-cli` crate contains the command line interface for the `tinywasm` project. See [`tinywasm`](https://crates.io/crates/tinywasm) for more information. +The `tinywasm-cli` package installs the `tinywasm` binary for `tinywasm`. See [`tinywasm`](https://crates.io/crates/tinywasm) for the embedding API. It is recommended to use the library directly instead of the CLI. +The crate also exposes reusable helpers such as `tinywasm_cli::wast_runner::WastRunner` so workspace tests can drive the same WAST execution logic directly. + ## Usage ```bash -$ cargo install tinywasm-cli -$ tinywasm-cli --help -$ tinywasm-cli run ./module.wasm -$ tinywasm-cli run ./module.wasm -f add -a i32:1 -a i32:2 +$ cargo install tinywasm-cli --version 0.9.0-alpha.0 --bin tinywasm +$ tinywasm --help +$ tinywasm ./module.wasm +$ tinywasm run --invoke add ./module.wasm 1 2 +$ tinywasm compile ./module.wat -o ./module.twasm +$ tinywasm dump ./module.twasm +$ tinywasm inspect ./module.wasm +$ tinywasm wast ./spec-tests/address.wast ``` + +Notes: + +- `run`, `dump`, and `inspect` accept `.wasm`, `.wat`, and `.twasm` inputs. +- Use `-` as the input path to read a module from stdin. +- Without `--invoke`, `tinywasm` expects the module to have a start function or `_start` export. +- `compile` writes TinyWasm's `twasm` archive format. +- Function invocation arguments are parsed from the export signature, so `tinywasm run --invoke add ./module.wasm 1 2` works without repeating Wasm types on the command line. +- `inspect` uses ANSI colors automatically when writing to a terminal; set `NO_COLOR=1` to disable them. +- Stack flags support both fixed sizes like `--value-stack-size 4096` and dynamic sizes like `--value-stack-dynamic 1024:8192`. +- `wast` is a separate command for WebAssembly spec scripts and accepts files or folders containing `.wast` files. diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs deleted file mode 100644 index 0333c92..0000000 --- a/crates/cli/src/args.rs +++ /dev/null @@ -1,37 +0,0 @@ -use std::str::FromStr; -use tinywasm::types::WasmValue; - -#[derive(Debug)] -pub struct WasmArg(WasmValue); - -pub fn to_wasm_args(args: Vec<WasmArg>) -> Vec<WasmValue> { - args.into_iter().map(Into::into).collect() -} - -impl From<WasmArg> for WasmValue { - fn from(value: WasmArg) -> Self { - value.0 - } -} - -impl FromStr for WasmArg { - type Err = String; - fn from_str(s: &str) -> std::prelude::v1::Result<Self, Self::Err> { - let [ty, val]: [&str; 2] = s - .split(':') - .collect::<Vec<_>>() - .try_into() - .map_err(|_e| "invalid argument format; expected type:value".to_string())?; - - let arg: WasmValue = match ty { - "i32" => val.parse::<i32>().map_err(|e| format!("invalid argument value for i32: {e:?}"))?.into(), - "i64" => val.parse::<i64>().map_err(|e| format!("invalid argument value for i64: {e:?}"))?.into(), - "f32" => val.parse::<f32>().map_err(|e| format!("invalid argument value for f32: {e:?}"))?.into(), - "f64" => val.parse::<f64>().map_err(|e| format!("invalid argument value for f64: {e:?}"))?.into(), - "v128" => val.parse::<i128>().map_err(|e| format!("invalid argument value for v128: {e:?}"))?.into(), - t => return Err(format!("invalid arg type `{t}`; expected one of i32, i64, f32, f64, v128")), - }; - - Ok(WasmArg(arg)) - } -} diff --git a/crates/cli/src/bin.rs b/crates/cli/src/bin.rs index 7e32fed..592fda7 100644 --- a/crates/cli/src/bin.rs +++ b/crates/cli/src/bin.rs @@ -1,118 +1,9 @@ -use std::str::FromStr; - -use argh::FromArgs; -use args::WasmArg; +use clap::Parser; use eyre::Result; -use log::{debug, info}; -use tinywasm::{Module, ModuleInstance, types::WasmValue}; - -use crate::args::to_wasm_args; -mod args; -mod util; - -#[cfg(feature = "wat")] -mod wat; - -#[derive(FromArgs)] -/// `TinyWasm` CLI -struct TinyWasmCli { - #[argh(subcommand)] - nested: TinyWasmSubcommand, - - /// log level: trace, debug, info, warn, or error - #[argh(option, short = 'l', default = "\"info\".to_string()")] - log_level: String, -} - -#[derive(FromArgs)] -#[argh(subcommand)] -enum TinyWasmSubcommand { - Run(Run), -} - -enum Engine { - Main, -} - -impl FromStr for Engine { - type Err = String; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - match s { - "main" => Ok(Self::Main), - _ => Err(format!("unknown engine: {s}")), - } - } -} - -#[derive(FromArgs)] -/// run a wasm file -#[argh(subcommand, name = "run")] -struct Run { - /// wasm file to run - #[argh(positional)] - wasm_file: String, - - /// exported function to run; omit to only instantiate and run start - #[argh(option, short = 'f')] - func: Option<String>, - - /// arguments passed to the function in type:value form - #[argh(option, short = 'a')] - args: Vec<WasmArg>, - - /// engine to use (currently only `main`) - #[argh(option, short = 'e', default = "Engine::Main")] - engine: Engine, -} +use tinywasm_cli::{Cli, run_cli}; fn main() -> Result<()> { - let args: TinyWasmCli = argh::from_env(); - let level = match args.log_level.as_str() { - "trace" => log::LevelFilter::Trace, - "debug" => log::LevelFilter::Debug, - "warn" => log::LevelFilter::Warn, - "error" => log::LevelFilter::Error, - "info" => log::LevelFilter::Info, - other => return Err(eyre::eyre!("invalid log level `{other}`; expected trace, debug, info, warn, or error")), - }; - - pretty_env_logger::formatted_builder().filter_level(level).init(); - let cwd = std::env::current_dir()?; - - match args.nested { - TinyWasmSubcommand::Run(Run { wasm_file, engine, args, func }) => { - debug!("args: {args:?}"); - - let path = cwd.join(&wasm_file); - let module = match wasm_file.ends_with(".wat") { - #[cfg(feature = "wat")] - true => { - let wat = std::fs::read_to_string(path)?; - let wasm = wat::wat2wasm(&wat); - tinywasm::parse_bytes(&wasm)? - } - #[cfg(not(feature = "wat"))] - true => return Err(eyre::eyre!("wat support is not enabled in this build")), - false => tinywasm::parse_file(path)?, - }; - - match engine { - Engine::Main => run(module, func, &to_wasm_args(args)), - } - } - } -} - -fn run(module: Module, func: Option<String>, args: &[WasmValue]) -> Result<()> { - let mut store = tinywasm::Store::default(); - let instance = ModuleInstance::instantiate(&mut store, &module, None)?; - - if let Some(func) = func { - let func = instance.func_untyped(&store, &func)?; - let res = func.call(&mut store, args)?; - info!("{res:?}"); - } - - Ok(()) + let cli = Cli::parse(); + pretty_env_logger::formatted_builder().filter_level(cli.log_level.into()).init(); + run_cli(cli) } diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs new file mode 100644 index 0000000..8b9df2d --- /dev/null +++ b/crates/cli/src/cli.rs @@ -0,0 +1,128 @@ +use clap::{ + Args, Parser, Subcommand, ValueEnum, + builder::{ + Styles, + styling::{AnsiColor, Effects}, + }, +}; +use clap_complete::Shell; + +use crate::engine_flags::EngineFlags; + +// based on https://github.com/crate-ci/clap-cargo/blob/master/src/style.rs +const STYLES: Styles = Styles::styled() + .header(AnsiColor::BrightGreen.on_default().effects(Effects::BOLD)) + .usage(AnsiColor::BrightGreen.on_default().effects(Effects::BOLD)) + .literal(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD)) + .placeholder(AnsiColor::Cyan.on_default()) + .error(AnsiColor::BrightRed.on_default().effects(Effects::BOLD)) + .valid(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD)) + .invalid(AnsiColor::Yellow.on_default()); + +#[derive(Parser)] +#[command( + name = "tinywasm", + about = "TinyWasm CLI", + styles = STYLES, + version, + args_conflicts_with_subcommands = true, + subcommand_negates_reqs = true +)] +pub struct Cli { + #[arg(long, global = true, value_enum, default_value_t = LogLevel::Info)] + pub log_level: LogLevel, + + #[command(subcommand)] + pub command: Option<Commands>, + + #[command(flatten)] + pub run: RunArgs, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Run a module + Run(RunArgs), + /// Compile a Wasm/WAT module to a .twasm archive + Compile(CompileArgs), + /// Dump lowered TinyWasm bytecode + Dump(ModuleInputArgs), + /// Inspect imports and exports + Inspect(ModuleInputArgs), + #[cfg(feature = "wast")] + /// Execute WebAssembly spec scripts (.wast) + Wast(WastArgs), + /// Generate shell completions + Completion(CompletionArgs), +} + +#[derive(Args, Clone)] +pub struct RunArgs { + /// Module path, or `-` to read from stdin + pub module: Option<String>, + + /// Invoke a named export instead of the default entrypoint + #[arg(long)] + pub invoke: Option<String>, + + #[command(flatten)] + pub engine: EngineFlags, + + /// Arguments passed to the invoked Wasm function + #[arg(trailing_var_arg = true)] + pub args: Vec<String>, +} + +#[derive(Args, Clone)] +pub struct CompileArgs { + /// Input module path, or `-` to read from stdin + pub input: String, + + /// Output path, or `-` to write to stdout + #[arg(short, long)] + pub output: Option<String>, + + /// Overwrite the output file if it already exists + #[arg(short, long)] + pub force: bool, +} + +#[derive(Args, Clone)] +pub struct ModuleInputArgs { + /// Module path, or `-` to read from stdin + pub module: String, +} + +#[derive(Args, Clone)] +pub struct CompletionArgs { + pub shell: Shell, +} + +#[cfg(feature = "wast")] +#[derive(Args, Clone)] +pub struct WastArgs { + /// WAST files or directories containing .wast files + #[arg(required = true)] + pub paths: Vec<String>, +} + +#[derive(Clone, Copy, ValueEnum)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl From<LogLevel> for log::LevelFilter { + fn from(value: LogLevel) -> Self { + match value { + LogLevel::Trace => log::LevelFilter::Trace, + LogLevel::Debug => log::LevelFilter::Debug, + LogLevel::Info => log::LevelFilter::Info, + LogLevel::Warn => log::LevelFilter::Warn, + LogLevel::Error => log::LevelFilter::Error, + } + } +} diff --git a/crates/cli/src/cmd/compile.rs b/crates/cli/src/cmd/compile.rs new file mode 100644 index 0000000..93e38d5 --- /dev/null +++ b/crates/cli/src/cmd/compile.rs @@ -0,0 +1,15 @@ +use eyre::Result; + +use crate::cli::CompileArgs; +use crate::load::{default_twasm_output_path, load_compilable_module, write_output_bytes}; + +pub fn run(args: CompileArgs) -> Result<()> { + let module = load_compilable_module(&args.input)?; + let twasm = module.serialize_twasm()?; + let output = match args.output { + Some(output) => output, + None => default_twasm_output_path(&args.input)?, + }; + + write_output_bytes(&output, &twasm, args.force) +} diff --git a/crates/cli/src/cmd/completion.rs b/crates/cli/src/cmd/completion.rs new file mode 100644 index 0000000..5815f57 --- /dev/null +++ b/crates/cli/src/cmd/completion.rs @@ -0,0 +1,12 @@ +use std::io; + +use clap::CommandFactory; +use eyre::Result; + +use crate::cli::{Cli, CompletionArgs}; + +pub fn run(args: CompletionArgs) -> Result<()> { + let mut cmd = Cli::command(); + clap_complete::generate(args.shell, &mut cmd, "tinywasm", &mut io::stdout()); + Ok(()) +} diff --git a/crates/cli/src/cmd/dump.rs b/crates/cli/src/cmd/dump.rs new file mode 100644 index 0000000..78fb28b --- /dev/null +++ b/crates/cli/src/cmd/dump.rs @@ -0,0 +1,71 @@ +use anstream::println; +use eyre::Result; +use owo_colors::OwoColorize; +use tinywasm::types::{ExternalKind, ImportKind}; + +use crate::cli::ModuleInputArgs; +use crate::load::load_module; + +pub fn run(args: ModuleInputArgs) -> Result<()> { + let loaded = load_module(&args.module)?; + let module = loaded.module; + + let imported_func_count = + module.imports.iter().filter(|import| matches!(import.kind, ImportKind::Function(_))).count() as u32; + + for (func_idx, func) in module.funcs.iter().enumerate() { + let global_idx = imported_func_count + func_idx as u32; + + let exports = module + .exports + .iter() + .filter(|export| export.kind == ExternalKind::Func && export.index == global_idx) + .map(|export| export.name.as_ref()) + .collect::<Vec<_>>(); + + let header = format!("func[{func_idx}]").blue().bold().to_string(); + if exports.is_empty() { + println!("{header}"); + } else { + println!("{header} {}", format!("exports={}", format!("{exports:?}").cyan()).bright_black()); + } + + for (ip, instr) in func.instructions.iter().enumerate() { + let instr = print_instr(instr); + println!(" {}: {}", print_ip(ip), instr); + } + println!(); + } + + Ok(()) +} + +fn print_ip(ip: usize) -> String { + let s = format!("{ip:04}"); + let first_non_zero = s.find(|c| c != '0').unwrap_or(s.len() - 1); + + format!( + "{}{}", + &s[..first_non_zero].to_string().bright_black().dimmed(), + &s[first_non_zero..].to_string().bright_black() + ) +} + +fn print_instr(instr: &tinywasm::types::Instruction) -> String { + let instr = format!("{instr:?}"); + let Some(split) = instr.find(['(', ' ', '{']) else { + return instr.bold().to_string(); + }; + + let (name, rest) = instr.split_at(split); + + let rest = rest + .replace('(', &"(".bright_black().to_string()) + .replace(')', &")".bright_black().to_string()) + .replace('{', &"{".bright_black().to_string()) + .replace('}', &"}".bright_black().to_string()) + .replace(',', &",".bright_black().to_string()) + .replace(':', &":".bright_black().to_string()); + + format!("{}{}", name.bold(), rest) +} diff --git a/crates/cli/src/cmd/inspect.rs b/crates/cli/src/cmd/inspect.rs new file mode 100644 index 0000000..ea2cec7 --- /dev/null +++ b/crates/cli/src/cmd/inspect.rs @@ -0,0 +1,35 @@ +use eyre::Result; + +use crate::cli::ModuleInputArgs; +use crate::load::load_module; +use crate::output::{format_export_type, format_import_type}; +use anstream::println; +use owo_colors::OwoColorize; + +pub fn run(args: ModuleInputArgs) -> Result<()> { + let loaded = load_module(&args.module)?; + let module = loaded.module; + + println!("{}", "Imports".bold()); + let mut import_count = 0usize; + for import in module.imports() { + import_count += 1; + println!(" {}.{}: {}", import.module.blue(), import.name.cyan(), format_import_type(import.ty).yellow()); + } + if import_count == 0 { + println!(" {}", "(none)".yellow()); + } + + println!(); + println!("{}", "Exports".bold()); + let mut export_count = 0usize; + for export in module.exports() { + export_count += 1; + println!(" {}: {}", export.name.green(), format_export_type(export.ty).yellow()); + } + if export_count == 0 { + println!(" {}", "(none)".yellow()); + } + + Ok(()) +} diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rs new file mode 100644 index 0000000..0861af2 --- /dev/null +++ b/crates/cli/src/cmd/mod.rs @@ -0,0 +1,7 @@ +pub mod compile; +pub mod completion; +pub mod dump; +pub mod inspect; +pub mod run; +#[cfg(feature = "wast")] +pub mod wast; diff --git a/crates/cli/src/cmd/run.rs b/crates/cli/src/cmd/run.rs new file mode 100644 index 0000000..b2f5e03 --- /dev/null +++ b/crates/cli/src/cmd/run.rs @@ -0,0 +1,47 @@ +use eyre::{Result, bail}; +use tinywasm::types::ExportType; +use tinywasm::{ModuleInstance, Store}; + +use crate::cli::RunArgs; +use crate::load::load_module; +use crate::output::print_results; +use crate::value_parse::parse_invocation_args; + +pub fn run(args: RunArgs) -> Result<()> { + let module_path = args.module.as_deref().ok_or_else(|| eyre::eyre!("missing module path"))?; + let loaded = load_module(module_path)?; + let mut store = Store::new(args.engine.build_engine()?); + let instance = ModuleInstance::instantiate_no_start(&mut store, &loaded.module, None)?; + + match args.invoke.as_deref() { + Some(export) => { + if loaded.module.start_func.is_some() { + let _ = instance.start(&mut store)?; + } + + let func_ty = loaded + .module + .exports() + .find_map(|item| match (item.name == export, item.ty) { + (true, ExportType::Func(ty)) => Some(ty), + _ => None, + }) + .ok_or_else(|| eyre::eyre!("export is not a function: {export}"))?; + let func = instance.func_untyped(&store, export)?; + let params = parse_invocation_args(func_ty, &args.args)?; + let results = func.call(&mut store, ¶ms)?; + print_results(&results); + Ok(()) + } + None => { + if instance.start_func(&store)?.is_none() { + bail!( + "module has no start function or `_start` export; use `tinywasm inspect {module_path}` or `tinywasm run --invoke <export> {module_path}`" + ) + } + + let _ = instance.start(&mut store)?; + Ok(()) + } + } +} diff --git a/crates/cli/src/cmd/wast.rs b/crates/cli/src/cmd/wast.rs new file mode 100644 index 0000000..ef9c263 --- /dev/null +++ b/crates/cli/src/cmd/wast.rs @@ -0,0 +1,12 @@ +use std::path::PathBuf; + +use eyre::Result; + +use crate::cli::WastArgs; +use crate::wast_runner::WastRunner; + +pub fn run(args: WastArgs) -> Result<()> { + let paths = args.paths.into_iter().map(PathBuf::from).collect::<Vec<_>>(); + let mut runner = WastRunner::new(); + runner.run_paths(&paths) +} diff --git a/crates/cli/src/engine_flags.rs b/crates/cli/src/engine_flags.rs new file mode 100644 index 0000000..2965a3e --- /dev/null +++ b/crates/cli/src/engine_flags.rs @@ -0,0 +1,154 @@ +use clap::{Args, ValueEnum}; +use eyre::{Result, bail}; +use tinywasm::{Engine, StackConfig, engine::FuelPolicy}; + +#[derive(Args, Clone, Default)] +pub struct EngineFlags { + /// Fuel accounting policy for budgeted execution APIs + #[arg(long, value_enum)] + pub fuel_policy: Option<FuelPolicyArg>, + + /// Trap immediately on memory or stack allocation failure + #[arg(long)] + pub trap_on_oom: bool, + + /// Memory backend to use for instantiated memories + #[arg(long, value_enum)] + pub memory_backend: Option<MemoryBackendArg>, + + /// Chunk size in bytes for the paged memory backend + #[arg(long, default_value_t = 64 * 1024)] + pub memory_page_chunk_size: usize, + + /// Fixed value stack size for all value lanes + #[arg(long, conflicts_with = "value_stack_dynamic")] + pub value_stack_size: Option<usize>, + + /// Dynamic value stack config in initial:max form for all value lanes + #[arg(long, value_name = "INITIAL:MAX", conflicts_with = "value_stack_size")] + pub value_stack_dynamic: Option<StackSpec>, + + /// Fixed call stack size in frames + #[arg(long, conflicts_with = "call_stack_dynamic")] + pub call_stack_size: Option<usize>, + + /// Dynamic call stack config in initial:max form + #[arg(long, value_name = "INITIAL:MAX", conflicts_with = "call_stack_size")] + pub call_stack_dynamic: Option<StackSpec>, +} + +#[derive(Clone, Copy, ValueEnum)] +pub enum FuelPolicyArg { + PerInstruction, + Weighted, +} + +#[derive(Clone, Copy, ValueEnum)] +pub enum MemoryBackendArg { + Vec, + Paged, +} + +#[derive(Clone)] +pub struct StackSpec { + initial: usize, + max: usize, +} + +impl core::str::FromStr for StackSpec { + type Err = String; + + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { + let (initial, max) = s.split_once(':').ok_or_else(|| "expected INITIAL:MAX".to_string())?; + let initial = initial.parse::<usize>().map_err(|e| format!("invalid initial stack size: {e}"))?; + let max = max.parse::<usize>().map_err(|e| format!("invalid max stack size: {e}"))?; + if initial > max { + return Err("initial stack size must be less than or equal to max stack size".to_string()); + } + Ok(Self { initial, max }) + } +} + +impl StackSpec { + fn into_stack_config(self) -> StackConfig { + StackConfig::dynamic(self.initial, self.max) + } +} + +impl EngineFlags { + pub fn build_engine(&self) -> Result<Engine> { + let mut config = tinywasm::engine::Config::new(); + + if let Some(fuel_policy) = self.fuel_policy { + config = config.with_fuel_policy(match fuel_policy { + FuelPolicyArg::PerInstruction => FuelPolicy::PerInstruction, + FuelPolicyArg::Weighted => FuelPolicy::Weighted, + }); + } + + if let Some(memory_backend) = self.memory_backend { + config = config.with_memory_backend(match memory_backend { + MemoryBackendArg::Vec => tinywasm::MemoryBackend::vec(), + MemoryBackendArg::Paged => { + if self.memory_page_chunk_size == 0 { + bail!("--memory-page-chunk-size must be greater than zero") + } + tinywasm::MemoryBackend::paged(self.memory_page_chunk_size) + } + }); + } + + if let Some(value_stack_size) = self.value_stack_size { + config = config.with_value_stack(StackConfig::fixed(value_stack_size)); + } + + if let Some(value_stack_dynamic) = self.value_stack_dynamic.clone() { + config = config.with_value_stack(value_stack_dynamic.into_stack_config()); + } + + if let Some(call_stack_size) = self.call_stack_size { + config = config.with_call_stack(StackConfig::fixed(call_stack_size)); + } + + if let Some(call_stack_dynamic) = self.call_stack_dynamic.clone() { + config = config.with_call_stack(call_stack_dynamic.into_stack_config()); + } + + if self.trap_on_oom { + config = config.with_trap_on_oom(true); + } + + Ok(Engine::new(config)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_stack_spec() { + let spec: StackSpec = "16:64".parse().unwrap(); + let cfg = spec.into_stack_config(); + assert_eq!(cfg.initial_size, 16); + assert_eq!(cfg.max_size, 64); + assert!(cfg.dynamic); + } + + #[test] + fn builds_dynamic_stack_engine() { + let flags = EngineFlags { + value_stack_dynamic: Some("8:32".parse().unwrap()), + call_stack_dynamic: Some("4:12".parse().unwrap()), + ..Default::default() + }; + + let engine = flags.build_engine().unwrap(); + assert!(engine.config().value_stack_32.dynamic); + assert_eq!(engine.config().value_stack_32.initial_size, 8); + assert_eq!(engine.config().value_stack_32.max_size, 32); + assert!(engine.config().call_stack.dynamic); + assert_eq!(engine.config().call_stack.initial_size, 4); + assert_eq!(engine.config().call_stack.max_size, 12); + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs new file mode 100644 index 0000000..bef6b72 --- /dev/null +++ b/crates/cli/src/lib.rs @@ -0,0 +1,34 @@ +pub mod cli; +pub mod cmd; +pub mod engine_flags; +pub mod load; +pub mod output; +pub mod value_parse; +#[cfg(feature = "wast")] +pub mod wast_runner; + +use clap::CommandFactory; +use eyre::Result; + +pub use cli::{Cli, Commands}; + +pub fn run_cli(cli: Cli) -> Result<()> { + match cli.command { + Some(Commands::Run(args)) => cmd::run::run(args), + Some(Commands::Compile(args)) => cmd::compile::run(args), + Some(Commands::Dump(args)) => cmd::dump::run(args), + Some(Commands::Inspect(args)) => cmd::inspect::run(args), + #[cfg(feature = "wast")] + Some(Commands::Wast(args)) => cmd::wast::run(args), + Some(Commands::Completion(args)) => cmd::completion::run(args), + None => match cli.run.module.as_deref() { + Some(_) => cmd::run::run(cli.run), + None => { + let mut cmd = Cli::command(); + cmd.print_help()?; + println!(); + Ok(()) + } + }, + } +} diff --git a/crates/cli/src/load.rs b/crates/cli/src/load.rs new file mode 100644 index 0000000..d16469b --- /dev/null +++ b/crates/cli/src/load.rs @@ -0,0 +1,103 @@ +use std::ffi::OsStr; +use std::io::{Read, Write}; +use std::path::Path; + +use eyre::{Context, Result, bail}; +use tinywasm::Module; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InputFormat { + Wasm, + Wat, + Twasm, +} + +pub struct LoadedModule { + pub module: Module, + pub format: InputFormat, +} + +pub fn load_module(input: &str) -> Result<LoadedModule> { + let bytes = read_input_bytes(input)?; + load_module_from_bytes(input, &bytes) +} + +pub fn load_compilable_module(input: &str) -> Result<Module> { + let loaded = load_module(input)?; + if loaded.format == InputFormat::Twasm { + bail!("input is already a twasm archive; use `run`, `dump`, or `inspect` instead") + } + Ok(loaded.module) +} + +pub fn default_twasm_output_path(input: &str) -> Result<String> { + if input == "-" { + bail!("--output is required when compiling from stdin") + } + + let path = Path::new(input); + let stem = path.file_stem().and_then(OsStr::to_str).unwrap_or("module"); + let output = path.with_file_name(format!("{stem}.twasm")); + Ok(output.to_string_lossy().into_owned()) +} + +pub fn write_output_bytes(output: &str, bytes: &[u8], force: bool) -> Result<()> { + if output == "-" { + std::io::stdout().write_all(bytes)?; + std::io::stdout().flush()?; + return Ok(()); + } + + let path = Path::new(output); + if path.exists() && !force { + bail!("output file already exists: {output}; pass --force to overwrite") + } + + std::fs::write(path, bytes).with_context(|| format!("failed to write output file `{output}`"))?; + Ok(()) +} + +fn load_module_from_bytes(input: &str, bytes: &[u8]) -> Result<LoadedModule> { + if bytes.starts_with(b"TWAS") { + let module = Module::try_from_twasm(bytes).with_context(|| format!("failed to read twasm input `{input}`"))?; + return Ok(LoadedModule { module, format: InputFormat::Twasm }); + } + + #[cfg(feature = "wat")] + if input != "-" && has_extension(input, "wat") { + let wasm = wat::parse_bytes(bytes).with_context(|| format!("failed to parse WAT input `{input}`"))?; + let module = + tinywasm::parse_bytes(&wasm).with_context(|| format!("failed to parse Wasm generated from `{input}`"))?; + return Ok(LoadedModule { module, format: InputFormat::Wat }); + } + + #[cfg(not(feature = "wat"))] + if input != "-" && has_extension(input, "wat") { + bail!("wat support is not enabled in this build") + } + + #[cfg(feature = "wat")] + if input == "-" + && let Ok(wasm) = wat::parse_bytes(bytes) + { + let module = tinywasm::parse_bytes(&wasm).context("failed to parse Wasm generated from stdin WAT input")?; + return Ok(LoadedModule { module, format: InputFormat::Wat }); + } + + let module = tinywasm::parse_bytes(bytes).with_context(|| format!("failed to parse Wasm input `{input}`"))?; + Ok(LoadedModule { module, format: InputFormat::Wasm }) +} + +fn read_input_bytes(input: &str) -> Result<Vec<u8>> { + if input == "-" { + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes).context("failed to read stdin")?; + return Ok(bytes); + } + + std::fs::read(input).with_context(|| format!("failed to read input `{input}`")) +} + +fn has_extension(path: &str, extension: &str) -> bool { + Path::new(path).extension().and_then(OsStr::to_str) == Some(extension) +} diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs new file mode 100644 index 0000000..bc0e450 --- /dev/null +++ b/crates/cli/src/output.rs @@ -0,0 +1,85 @@ +use tinywasm::types::{ + ExportType, FuncType, GlobalType, ImportType, MemoryArch, MemoryType, TableType, WasmType, WasmValue, +}; + +pub fn print_results(results: &[WasmValue]) { + match results { + [] => {} + [value] => println!("{}", format_value(value)), + values => { + let formatted = values.iter().map(format_value).collect::<Vec<_>>().join(", "); + println!("[{formatted}]"); + } + } +} + +pub fn format_value(value: &WasmValue) -> String { + format!("{value:?}") +} + +pub fn color_enabled() -> bool { + use std::io::IsTerminal; + + std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() +} + +pub fn format_wasm_type(ty: WasmType) -> &'static str { + match ty { + WasmType::I32 => "i32", + WasmType::I64 => "i64", + WasmType::F32 => "f32", + WasmType::F64 => "f64", + WasmType::V128 => "v128", + WasmType::RefFunc => "funcref", + WasmType::RefExtern => "externref", + } +} + +pub fn format_func_type(ty: &FuncType) -> String { + let params = ty.params().iter().map(|ty| format_wasm_type(*ty)).collect::<Vec<_>>().join(", "); + let results = ty.results().iter().map(|ty| format_wasm_type(*ty)).collect::<Vec<_>>().join(", "); + + if ty.results().is_empty() { format!("({params})") } else { format!("({params}) -> ({results})") } +} + +pub fn format_memory_type(ty: &MemoryType) -> String { + let arch = match ty.arch() { + MemoryArch::I32 => "i32", + MemoryArch::I64 => "i64", + }; + let max = if ty.page_count_max() == ty.page_count_initial() && ty.max_size() == ty.initial_size() { + ty.page_count_initial().to_string() + } else { + ty.page_count_max().to_string() + }; + + format!("memory[{arch}] initial={} max={} page_size={}", ty.page_count_initial(), max, ty.page_size()) +} + +pub fn format_table_type(ty: &TableType) -> String { + let max = ty.size_max.map(|v| v.to_string()).unwrap_or_else(|| "unbounded".to_string()); + format!("table[{}] initial={} max={max}", format_wasm_type(ty.element_type), ty.size_initial) +} + +pub fn format_global_type(ty: &GlobalType) -> String { + let mutability = if ty.mutable { "mut" } else { "const" }; + format!("global[{mutability} {}]", format_wasm_type(ty.ty)) +} + +pub fn format_export_type(ty: ExportType<'_>) -> String { + match ty { + ExportType::Func(ty) => format!("func {}", format_func_type(ty)), + ExportType::Memory(ty) => format_memory_type(ty), + ExportType::Table(ty) => format_table_type(ty), + ExportType::Global(ty) => format_global_type(ty), + } +} + +pub fn format_import_type(ty: ImportType<'_>) -> String { + match ty { + ImportType::Func(ty) => format!("func {}", format_func_type(ty)), + ImportType::Memory(ty) => format_memory_type(ty), + ImportType::Table(ty) => format_table_type(ty), + ImportType::Global(ty) => format_global_type(ty), + } +} diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs deleted file mode 100644 index 8b13789..0000000 --- a/crates/cli/src/util.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/cli/src/value_parse.rs b/crates/cli/src/value_parse.rs new file mode 100644 index 0000000..f64d335 --- /dev/null +++ b/crates/cli/src/value_parse.rs @@ -0,0 +1,55 @@ +use eyre::{Result, bail}; +use tinywasm::types::{FuncType, WasmType, WasmValue}; + +use crate::output::format_wasm_type; + +pub fn parse_invocation_args(ty: &FuncType, args: &[String]) -> Result<Vec<WasmValue>> { + if args.len() != ty.params().len() { + bail!("wrong number of arguments: expected {}, got {}", ty.params().len(), args.len()) + } + + ty.params().iter().enumerate().map(|(idx, param_ty)| parse_arg(idx, *param_ty, &args[idx])).collect() +} + +fn parse_arg(index: usize, ty: WasmType, value: &str) -> Result<WasmValue> { + let parsed = match ty { + WasmType::I32 => value.parse::<i32>().map(WasmValue::from).map_err(|e| format_error(index, ty, value, e))?, + WasmType::I64 => value.parse::<i64>().map(WasmValue::from).map_err(|e| format_error(index, ty, value, e))?, + WasmType::F32 => value.parse::<f32>().map(WasmValue::from).map_err(|e| format_error(index, ty, value, e))?, + WasmType::F64 => value.parse::<f64>().map(WasmValue::from).map_err(|e| format_error(index, ty, value, e))?, + WasmType::V128 => value.parse::<i128>().map(WasmValue::from).map_err(|e| format_error(index, ty, value, e))?, + WasmType::RefFunc | WasmType::RefExtern => { + bail!( + "unsupported CLI argument type at position {}: {}; use the embedding API for reference values", + index + 1, + format_wasm_type(ty) + ) + } + }; + + Ok(parsed) +} + +fn format_error(index: usize, ty: WasmType, value: &str, error: impl core::fmt::Display) -> eyre::Report { + eyre::eyre!("failed to parse argument {} as {} from `{value}`: {error}", index + 1, format_wasm_type(ty)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_numeric_args() { + let ty = FuncType::new(&[WasmType::I32, WasmType::F64], &[WasmType::I32]); + let args = vec!["1".to_string(), "2.5".to_string()]; + let parsed = parse_invocation_args(&ty, &args).unwrap(); + assert_eq!(parsed, vec![WasmValue::I32(1), WasmValue::F64(2.5)]); + } + + #[test] + fn rejects_wrong_arity() { + let ty = FuncType::new(&[WasmType::I32], &[]); + let err = parse_invocation_args(&ty, &[]).unwrap_err(); + assert!(err.to_string().contains("wrong number of arguments")); + } +} diff --git a/crates/cli/src/wast_runner.rs b/crates/cli/src/wast_runner.rs new file mode 100644 index 0000000..8a759b7 --- /dev/null +++ b/crates/cli/src/wast_runner.rs @@ -0,0 +1,885 @@ +use std::collections::{BTreeMap, HashMap}; +use std::fmt::{Display, Formatter}; +use std::fs::canonicalize; +use std::path::PathBuf; +use std::{ + panic::{self, AssertUnwindSafe}, + time::Duration, +}; + +use eyre::{Context, Result, bail, eyre}; +use log::{debug, error}; +use tinywasm::types::{ExternRef, FuncRef, MemoryType, TableType, WasmType, WasmValue}; +use tinywasm::{ExecProgress, Global, HostFunction, Imports, Memory, Module, ModuleInstance, Store, Table}; +use wast::{QuoteWat, core::AbstractHeapType}; + +const TEST_TIME_SLICE: Duration = Duration::from_millis(20); +const TEST_MAX_SUSPENSIONS: u32 = 1000; + +#[derive(Default)] +struct ModuleRegistry { + modules: HashMap<String, ModuleInstance>, + named_modules: HashMap<String, ModuleInstance>, + last_module: Option<ModuleInstance>, +} + +impl ModuleRegistry { + fn modules(&self) -> &HashMap<String, ModuleInstance> { + &self.modules + } + + fn update_last_module(&mut self, module: ModuleInstance, name: Option<String>) { + self.last_module = Some(module.clone()); + if let Some(name) = name { + self.named_modules.insert(name, module); + } + } + + fn register(&mut self, name: String, module: ModuleInstance) { + debug!("registering module: {name}"); + self.modules.insert(name.clone(), module.clone()); + self.last_module = Some(module.clone()); + self.named_modules.insert(name, module); + } + + fn get_idx(&self, module_id: Option<wast::token::Id<'_>>) -> Option<u32> { + match module_id { + Some(module) => self + .modules + .get(module.name()) + .or_else(|| self.named_modules.get(module.name())) + .map(ModuleInstance::id), + None => self.last_module.as_ref().map(ModuleInstance::id), + } + } + + fn get(&self, module_id: Option<wast::token::Id<'_>>) -> Option<ModuleInstance> { + match module_id { + Some(module_id) => { + self.modules.get(module_id.name()).or_else(|| self.named_modules.get(module_id.name())).cloned() + } + None => self.last_module.clone(), + } + } + + fn last(&self) -> Option<ModuleInstance> { + self.last_module.clone() + } +} + +#[derive(Default)] +pub struct WastRunner(BTreeMap<String, TestGroup>); + +#[derive(Clone, Debug)] +pub struct GroupResult { + pub name: String, + pub file: String, + pub passed: usize, + pub failed: usize, +} + +impl WastRunner { + pub fn new() -> Self { + Self::default() + } + + pub fn run_paths(&mut self, tests: &[PathBuf]) -> Result<()> { + for path in expand_paths(tests)? { + let contents = + std::fs::read_to_string(&path).context(format!("failed to read file: {}", path.to_string_lossy()))?; + + let file = TestFile { + contents: &contents, + name: path.to_string_lossy().to_string(), + parent: canonicalize(&path)?.to_string_lossy().to_string(), + }; + + self.run_file(file)?; + } + + self.print_errors(); + if self.failed() { + anstream::println!("{self}"); + Err(eyre!("failed one or more tests")) + } else { + anstream::println!("{self}"); + Ok(()) + } + } + + pub fn set_log_level(level: log::LevelFilter) { + let _ = pretty_env_logger::formatted_builder().filter_level(level).try_init(); + } + + pub fn failed(&self) -> bool { + self.0.values().any(|group| group.stats().1 > 0) + } + + pub fn print_errors(&self) { + for group in self.0.values() { + for test in &group.tests { + if let Err(err) = &test.result { + eprintln!( + "{}:{}:{} {} failed: {}", + group.file, + test.linecol.0 + 1, + test.linecol.1 + 1, + test.name, + err + ); + } + } + } + } + + pub fn group_results(&self) -> Vec<GroupResult> { + self.0 + .iter() + .map(|(name, group)| { + let (passed, failed) = group.stats(); + GroupResult { name: name.clone(), file: group.file.clone(), passed, failed } + }) + .collect() + } + + fn test_group(&mut self, name: &str, file: &str) -> &mut TestGroup { + self.0.entry(name.to_string()).or_insert_with(|| TestGroup::new(file)) + } + + pub fn run_files<'a>(&mut self, tests: impl IntoIterator<Item = TestFile<'a>>) -> Result<()> { + for file in tests { + self.run_file(file)?; + } + Ok(()) + } + + fn imports(store: &mut Store, modules: &HashMap<String, ModuleInstance>) -> Result<Imports> { + let mut imports = Imports::new(); + + let table = Table::new( + store, + TableType::new(WasmType::RefFunc, 10, Some(20)), + WasmValue::default_for(WasmType::RefFunc), + )?; + let memory = Memory::new(store, MemoryType::default().with_page_count_initial(1).with_page_count_max(Some(2)))?; + let global_i32 = + Global::new(store, tinywasm::types::GlobalType::new(WasmType::I32, false), WasmValue::I32(666))?; + let global_i64 = + Global::new(store, tinywasm::types::GlobalType::new(WasmType::I64, false), WasmValue::I64(666))?; + let global_f32 = + Global::new(store, tinywasm::types::GlobalType::new(WasmType::F32, false), WasmValue::F32(666.6))?; + let global_f64 = + Global::new(store, tinywasm::types::GlobalType::new(WasmType::F64, false), WasmValue::F64(666.6))?; + + imports + .define("spectest", "memory", memory) + .define("spectest", "table", table) + .define("spectest", "global_i32", global_i32) + .define("spectest", "global_i64", global_i64) + .define("spectest", "global_f32", global_f32) + .define("spectest", "global_f64", global_f64) + .define("spectest", "print", HostFunction::from(store, |_ctx: tinywasm::FuncContext, (): ()| Ok(()))) + .define("spectest", "print_i32", HostFunction::from(store, |_ctx: tinywasm::FuncContext, _arg: i32| Ok(()))) + .define("spectest", "print_i64", HostFunction::from(store, |_ctx: tinywasm::FuncContext, _arg: i64| Ok(()))) + .define("spectest", "print_f32", HostFunction::from(store, |_ctx: tinywasm::FuncContext, _arg: f32| Ok(()))) + .define("spectest", "print_f64", HostFunction::from(store, |_ctx: tinywasm::FuncContext, _arg: f64| Ok(()))) + .define( + "spectest", + "print_i32_f32", + HostFunction::from(store, |_ctx: tinywasm::FuncContext, _args: (i32, f32)| Ok(())), + ) + .define( + "spectest", + "print_f64_f64", + HostFunction::from(store, |_ctx: tinywasm::FuncContext, _args: (f64, f64)| Ok(())), + ); + + for (name, module) in modules { + imports.link_module(name, module.clone())?; + } + + Ok(imports) + } + + pub fn run_file(&mut self, file: TestFile<'_>) -> Result<()> { + let test_group = self.test_group(file.name(), file.parent()); + let wast_raw = file.raw(); + let wast = file.wast()?; + let directives = wast.directives()?; + + let mut store = Store::default(); + let mut module_registry = ModuleRegistry::default(); + + println!("running {} tests for group: {}", directives.len(), file.name()); + for (i, directive) in directives.into_iter().enumerate() { + let span = directive.span(); + use wast::WastDirective::{ + AssertExhaustion, AssertInvalid, AssertMalformed, AssertReturn, AssertTrap, AssertUnlinkable, Invoke, + Module as Wat, Register, + }; + + match directive { + Register { span, name, .. } => { + let Some(last) = module_registry.last() else { + test_group.add_result( + &format!("Register({i})"), + span.linecol_in(wast_raw), + Err(eyre!("no module to register")), + ); + continue; + }; + module_registry.register(name.to_string(), last); + test_group.add_result(&format!("Register({i})"), span.linecol_in(wast_raw), Ok(())); + } + Wat(module) => { + let result = catch_unwind_silent(|| { + let (name, bytes) = encode_quote_wat(module); + let module = parse_module_bytes(&bytes).expect("failed to parse module bytes"); + let imports = Self::imports(&mut store, module_registry.modules()).unwrap(); + let module_instance = ModuleInstance::instantiate(&mut store, &module, Some(imports)) + .expect("failed to instantiate module"); + (name, module_instance) + }) + .map_err(|e| eyre!("failed to parse wat module: {}", try_downcast_panic(e))); + + match &result { + Err(err) => debug!("failed to parse module: {err:?}"), + Ok((name, module)) => module_registry.update_last_module(module.clone(), name.clone()), + }; + + test_group.add_result(&format!("Wat({i})"), span.linecol_in(wast_raw), result.map(|_| ())); + } + AssertMalformed { span, mut module, message } => { + let Ok(encoded) = module.encode() else { + test_group.add_result(&format!("AssertMalformed({i})"), span.linecol_in(wast_raw), Ok(())); + continue; + }; + let res = catch_unwind_silent(|| parse_module_bytes(&encoded)) + .map_err(|e| eyre!("failed to parse module (expected): {}", try_downcast_panic(e))) + .and_then(|res| res); + test_group.add_result( + &format!("AssertMalformed({i})"), + span.linecol_in(wast_raw), + match res { + Ok(_) => { + if message == "zero byte expected" + || message == "integer representation too long" + || message == "zero flag expected" + { + continue; + } + Err(eyre!("expected module to be malformed: {message}")) + } + Err(_) => Ok(()), + }, + ); + } + AssertInvalid { span, mut module, message } => { + if ["multiple memories", "type mismatch"].contains(&message) { + test_group.add_result(&format!("AssertInvalid({i})"), span.linecol_in(wast_raw), Ok(())); + continue; + } + let res = catch_unwind_silent(move || parse_module_bytes(&module.encode().unwrap())) + .map_err(|e| eyre!("failed to parse module (invalid): {}", try_downcast_panic(e))) + .and_then(|res| res); + test_group.add_result( + &format!("AssertInvalid({i})"), + span.linecol_in(wast_raw), + match res { + Ok(_) => Err(eyre!("expected module to be invalid")), + Err(_) => Ok(()), + }, + ); + } + AssertExhaustion { call, message, span } => { + let module = module_registry.get_idx(call.module); + let args = convert_wastargs(call.args)?; + let res = + catch_unwind_silent(|| exec_fn_instance(module, &mut store, call.name, &args).map(|_| ())); + let Ok(Err(tinywasm::Error::Trap(trap))) = res else { + test_group.add_result( + &format!("AssertExhaustion({i})"), + span.linecol_in(wast_raw), + Err(eyre!("expected trap")), + ); + continue; + }; + if !message.starts_with(trap.message()) && !trap.message().starts_with(message) { + test_group.add_result( + &format!("AssertExhaustion({i})"), + span.linecol_in(wast_raw), + Err(eyre!("expected trap: {}, got: {}", message, trap.message())), + ); + continue; + } + test_group.add_result(&format!("AssertExhaustion({i})"), span.linecol_in(wast_raw), Ok(())); + } + AssertTrap { exec, message, span } => { + let res: Result<tinywasm::Result<()>, _> = catch_unwind_silent(|| { + let invoke = match exec { + wast::WastExecute::Wat(mut wat) => { + let module = parse_module_bytes(&wat.encode().expect("failed to encode module")) + .expect("failed to parse module"); + let imports = Self::imports(&mut store, module_registry.modules()).unwrap(); + ModuleInstance::instantiate(&mut store, &module, Some(imports))?; + return Ok(()); + } + wast::WastExecute::Get { .. } => panic!("get not supported"), + wast::WastExecute::Invoke(invoke) => invoke, + }; + let module = module_registry.get_idx(invoke.module); + let args = + convert_wastargs(invoke.args).map_err(|err| tinywasm::Error::Other(err.to_string()))?; + exec_fn_instance(module, &mut store, invoke.name, &args).map(|_| ()) + }); + match res { + Err(err) => test_group.add_result( + &format!("AssertTrap({i})"), + span.linecol_in(wast_raw), + Err(eyre!("test panicked: {}", try_downcast_panic(err))), + ), + Ok(Err(tinywasm::Error::Trap(trap))) => { + if !message.starts_with(trap.message()) && !trap.message().starts_with(message) { + test_group.add_result( + &format!("AssertTrap({i})"), + span.linecol_in(wast_raw), + Err(eyre!("expected trap: {}, got: {}", message, trap.message())), + ); + continue; + } + test_group.add_result(&format!("AssertTrap({i})"), span.linecol_in(wast_raw), Ok(())); + } + Ok(Err(err)) => test_group.add_result( + &format!("AssertTrap({i})"), + span.linecol_in(wast_raw), + Err(eyre!("expected trap, {}, got: {:?}", message, err)), + ), + Ok(Ok(())) => test_group.add_result( + &format!("AssertTrap({i})"), + span.linecol_in(wast_raw), + Err(eyre!("expected trap {}, got Ok", message)), + ), + } + } + AssertUnlinkable { mut module, span, message } => { + let res = catch_unwind_silent(|| { + let module = parse_module_bytes(&module.encode().expect("failed to encode module")) + .expect("failed to parse module"); + let imports = Self::imports(&mut store, module_registry.modules()).unwrap(); + ModuleInstance::instantiate(&mut store, &module, Some(imports)) + }); + match res { + Err(err) => test_group.add_result( + &format!("AssertUnlinkable({i})"), + span.linecol_in(wast_raw), + Err(eyre!("test panicked: {}", try_downcast_panic(err))), + ), + Ok(Err(tinywasm::Error::Linker(err))) => { + if err.message() != message + && (err.message() == "memory types incompatible" + && message != "incompatible import type") + { + test_group.add_result( + &format!("AssertUnlinkable({i})"), + span.linecol_in(wast_raw), + Err(eyre!("expected linker error: {}, got: {}", message, err.message())), + ); + continue; + } + test_group.add_result(&format!("AssertUnlinkable({i})"), span.linecol_in(wast_raw), Ok(())); + } + Ok(Err(err)) => test_group.add_result( + &format!("AssertUnlinkable({i})"), + span.linecol_in(wast_raw), + Err(eyre!("expected linker error, {}, got: {:?}", message, err)), + ), + Ok(Ok(_)) => test_group.add_result( + &format!("AssertUnlinkable({i})"), + span.linecol_in(wast_raw), + Err(eyre!("expected linker error {}, got Ok", message)), + ), + } + } + Invoke(invoke) => { + let name = invoke.name; + let res: Result<Result<()>, _> = catch_unwind_silent(|| { + let args = convert_wastargs(invoke.args)?; + let module = module_registry.get_idx(invoke.module); + exec_fn_instance(module, &mut store, invoke.name, &args).map_err(|e| { + error!("failed to execute function: {e:?}"); + e + })?; + Ok(()) + }); + let res = res.map_err(|e| eyre!("test panicked: {}", try_downcast_panic(e))).and_then(|r| r); + test_group.add_result(&format!("Invoke({name}-{i})"), span.linecol_in(wast_raw), res); + } + AssertReturn { span, exec, results } => { + let expected_alternatives = match convert_wastret(results.into_iter()) { + Err(err) => { + test_group.add_result( + &format!("AssertReturn(unsupported-{i})"), + span.linecol_in(wast_raw), + Err(eyre!("failed to convert expected results: {err:?}")), + ); + continue; + } + Ok(expected) => expected, + }; + + let invoke = match match exec { + wast::WastExecute::Wat(_) => Err(eyre!("wat not supported")), + wast::WastExecute::Get { module: module_id, global, .. } => { + let Some(module) = module_registry.get(module_id) else { + test_group.add_result( + &format!("AssertReturn(unsupported-{i})"), + span.linecol_in(wast_raw), + Err(eyre!("no module to get global from")), + ); + continue; + }; + let module_global = match module.global_get(&store, global) { + Ok(value) => value, + Err(err) => { + test_group.add_result( + &format!("AssertReturn(unsupported-{i})"), + span.linecol_in(wast_raw), + Err(eyre!("failed to get global: {err:?}")), + ); + continue; + } + }; + let expected = expected_alternatives + .iter() + .filter_map(|alts| alts.first()) + .find(|exp| module_global.eq_loose(exp)); + if expected.is_none() { + test_group.add_result( + &format!("AssertReturn(unsupported-{i})"), + span.linecol_in(wast_raw), + Err(eyre!( + "global value did not match any expected alternative: {:?}", + module_global + )), + ); + continue; + } + test_group.add_result( + &format!("AssertReturn({global}-{i})"), + span.linecol_in(wast_raw), + Ok(()), + ); + continue; + } + wast::WastExecute::Invoke(invoke) => Ok(invoke), + } { + Ok(invoke) => invoke, + Err(err) => { + test_group.add_result( + &format!("AssertReturn(unsupported-{i})"), + span.linecol_in(wast_raw), + Err(eyre!("unsupported directive: {err:?}")), + ); + continue; + } + }; + + let invoke_name = invoke.name; + let res: Result<Result<()>, _> = catch_unwind_silent(|| { + let args = convert_wastargs(invoke.args)?; + let module = module_registry.get_idx(invoke.module); + let outcomes = exec_fn_instance(module, &mut store, invoke.name, &args).map_err(|e| { + error!("failed to execute function: {e:?}"); + e + })?; + if !expected_alternatives.iter().any(|expected| expected.len() == outcomes.len()) { + return Err(eyre!( + "expected {} results, got {}", + expected_alternatives.first().map_or(0, |v| v.len()), + outcomes.len() + )); + } + if expected_alternatives.iter().any(|expected| { + expected.len() == outcomes.len() + && outcomes.iter().zip(expected.iter()).all(|(outcome, exp)| outcome.eq_loose(exp)) + }) { + Ok(()) + } else { + Err(eyre!("results did not match any expected alternative")) + } + }); + + let res = res.map_err(|e| eyre!("test panicked: {}", try_downcast_panic(e))).and_then(|r| r); + test_group.add_result(&format!("AssertReturn({invoke_name}-{i})"), span.linecol_in(wast_raw), res); + } + _ => test_group.add_result( + &format!("Unknown({i})"), + span.linecol_in(wast_raw), + Err(eyre!("unsupported directive")), + ), + } + } + + Ok(()) + } +} + +impl Display for WastRunner { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + use owo_colors::OwoColorize; + + let mut total_passed = 0; + let mut total_failed = 0; + + for group in self.group_results() { + total_passed += group.passed; + total_failed += group.failed; + + writeln!(f, "{}", group.name.bold().underline())?; + writeln!(f, " Tests Passed: {}", group.passed.to_string().green())?; + if group.failed != 0 { + writeln!(f, " Tests Failed: {}", group.failed.to_string().red())?; + } + } + + writeln!(f, "\n{}", "Total Test Summary:".bold().underline())?; + writeln!(f, " Total Tests: {}", total_passed + total_failed)?; + writeln!(f, " Total Passed: {}", total_passed.to_string().green())?; + writeln!(f, " Total Failed: {}", total_failed.to_string().red())?; + Ok(()) + } +} + +#[derive(Debug)] +struct TestGroup { + tests: Vec<TestCase>, + file: String, +} + +impl TestGroup { + fn new(file: &str) -> Self { + Self { tests: Vec::new(), file: file.to_string() } + } + + fn stats(&self) -> (usize, usize) { + let mut passed = 0; + let mut failed = 0; + for test in &self.tests { + match test.result { + Ok(()) => passed += 1, + Err(_) => failed += 1, + } + } + (passed, failed) + } + + fn add_result(&mut self, name: &str, linecol: (usize, usize), result: Result<()>) { + self.tests.push(TestCase { name: name.to_string(), linecol, result }); + } +} + +#[derive(Debug)] +struct TestCase { + name: String, + linecol: (usize, usize), + result: Result<()>, +} + +fn expand_paths(paths: &[PathBuf]) -> Result<Vec<PathBuf>> { + let mut files = Vec::new(); + for path in paths { + if path.is_dir() { + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "wast") { + files.push(path); + } + } + } else { + files.push(path.clone()); + } + } + files.sort(); + Ok(files) +} + +#[derive(Debug)] +pub struct TestFile<'a> { + pub name: String, + pub contents: &'a str, + pub parent: String, +} + +impl<'a> TestFile<'a> { + pub fn name(&self) -> &str { + &self.name + } + + pub fn raw(&self) -> &'a str { + self.contents + } + + pub fn parent(&self) -> &str { + &self.parent + } + + pub fn wast(&self) -> wast::parser::Result<WastBuffer<'a>> { + let mut lexer = wast::lexer::Lexer::new(self.contents); + lexer.allow_confusing_unicode(true); + let parse_buffer = wast::parser::ParseBuffer::new_with_lexer(lexer)?; + Ok(WastBuffer { buffer: parse_buffer }) + } +} + +pub struct WastBuffer<'a> { + buffer: wast::parser::ParseBuffer<'a>, +} + +impl<'a> WastBuffer<'a> { + pub fn directives(&'a self) -> wast::parser::Result<Vec<wast::WastDirective<'a>>> { + Ok(wast::parser::parse::<wast::Wast<'a>>(&self.buffer)?.directives) + } +} + +fn exec_with_budget( + func: &tinywasm::Function, + store: &mut Store, + args: &[WasmValue], +) -> Result<Vec<WasmValue>, tinywasm::Error> { + let mut exec = func.call_resumable(store, args)?; + for _ in 0..TEST_MAX_SUSPENSIONS { + match exec.resume_with_time_budget(TEST_TIME_SLICE)? { + ExecProgress::Completed(values) => return Ok(values), + ExecProgress::Suspended => {} + } + } + Err(tinywasm::Error::Other(format!( + "testsuite execution timed out after {} time slices of {:?}", + TEST_MAX_SUSPENSIONS, TEST_TIME_SLICE + ))) +} + +fn try_downcast_panic(panic: Box<dyn std::any::Any + Send>) -> String { + let info = panic.downcast_ref::<panic::PanicHookInfo>().map(ToString::to_string); + let info_string = panic.downcast_ref::<String>().cloned(); + let info_str = panic.downcast::<&str>().ok().map(|s| *s); + info.unwrap_or_else(|| info_str.unwrap_or(&info_string.unwrap_or("unknown panic".to_owned())).to_string()) +} + +fn exec_fn_instance( + instance: Option<u32>, + store: &mut Store, + name: &str, + args: &[WasmValue], +) -> Result<Vec<WasmValue>, tinywasm::Error> { + let Some(instance) = instance else { + return Err(tinywasm::Error::Other("no instance found".to_string())); + }; + let Some(instance) = store.get_module_instance(instance) else { + return Err(tinywasm::Error::Other("no instance found".to_string())); + }; + let func = instance.func_untyped(store, name)?; + exec_with_budget(&func, store, args) +} + +fn catch_unwind_silent<R>(f: impl FnOnce() -> R) -> std::thread::Result<R> { + let prev_hook = panic::take_hook(); + panic::set_hook(Box::new(|_| {})); + let result = panic::catch_unwind(AssertUnwindSafe(f)); + panic::set_hook(prev_hook); + result +} + +fn encode_quote_wat(module: QuoteWat) -> (Option<String>, Vec<u8>) { + match module { + QuoteWat::QuoteModule(_, quoted_wat) => { + let wat = quoted_wat + .iter() + .map(|(_, s)| std::str::from_utf8(s).expect("failed to convert wast to utf8")) + .collect::<Vec<_>>() + .join("\n"); + let lexer = wast::lexer::Lexer::new(&wat); + let buf = wast::parser::ParseBuffer::new_with_lexer(lexer).expect("failed to create parse buffer"); + let mut wat_data = wast::parser::parse::<wast::Wat>(&buf).expect("failed to parse wat"); + (None, wat_data.encode().expect("failed to encode module")) + } + QuoteWat::Wat(mut wat) => { + let wast::Wat::Module(ref module) = wat else { unimplemented!("Not supported") }; + (module.id.map(|id| id.name().to_string()), wat.encode().expect("failed to encode module")) + } + QuoteWat::QuoteComponent(..) => unimplemented!("components are not supported"), + } +} + +fn parse_module_bytes(bytes: &[u8]) -> Result<Module> { + Ok(tinywasm::parse_bytes(bytes)?) +} + +fn convert_wastargs(args: Vec<wast::WastArg>) -> Result<Vec<WasmValue>> { + args.into_iter().map(wastarg2tinywasmvalue).collect() +} + +fn convert_wastret<'a>(args: impl Iterator<Item = wast::WastRet<'a>>) -> Result<Vec<Vec<WasmValue>>> { + let mut alternatives = vec![Vec::new()]; + for arg in args { + let choices = wastret2tinywasmvalues(arg)?; + let mut next = Vec::with_capacity(alternatives.len() * choices.len()); + for prefix in alternatives { + for choice in &choices { + let mut candidate = prefix.clone(); + candidate.push(*choice); + next.push(candidate); + } + } + alternatives = next; + } + Ok(alternatives) +} + +fn wastarg2tinywasmvalue(arg: wast::WastArg) -> Result<WasmValue> { + let wast::WastArg::Core(arg) = arg else { bail!("unsupported arg type: Component") }; + use wast::core::WastArgCore::*; + Ok(match arg { + F32(f) => WasmValue::F32(f32::from_bits(f.bits)), + F64(f) => WasmValue::F64(f64::from_bits(f.bits)), + I32(i) => WasmValue::I32(i), + I64(i) => WasmValue::I64(i), + V128(i) => WasmValue::V128(i128::from_le_bytes(i.to_le_bytes())), + RefExtern(v) => WasmValue::RefExtern(ExternRef::new(Some(v))), + RefNull(t) => match t { + wast::core::HeapType::Abstract { shared: false, ty: AbstractHeapType::Func } => { + WasmValue::RefFunc(FuncRef::null()) + } + wast::core::HeapType::Abstract { shared: false, ty: AbstractHeapType::Extern } => { + WasmValue::RefExtern(ExternRef::null()) + } + _ => bail!("unsupported arg type: refnull: {:?}", t), + }, + RefHost(_) => bail!("unsupported arg type: RefHost"), + }) +} + +fn wast_i128_to_i128(i: wast::core::V128Pattern) -> i128 { + let res: Vec<u8> = match i { + wast::core::V128Pattern::F32x4(f) => { + f.iter().flat_map(|v| nanpattern2tinywasmvalue(*v).unwrap().as_f32().unwrap().to_le_bytes()).collect() + } + wast::core::V128Pattern::F64x2(f) => { + f.iter().flat_map(|v| nanpattern2tinywasmvalue(*v).unwrap().as_f64().unwrap().to_le_bytes()).collect() + } + wast::core::V128Pattern::I16x8(f) => f.iter().flat_map(|v| v.to_le_bytes()).collect(), + wast::core::V128Pattern::I32x4(f) => f.iter().flat_map(|v| v.to_le_bytes()).collect(), + wast::core::V128Pattern::I64x2(f) => f.iter().flat_map(|v| v.to_le_bytes()).collect(), + wast::core::V128Pattern::I8x16(f) => f.iter().flat_map(|v| v.to_le_bytes()).collect(), + }; + i128::from_le_bytes(res.try_into().unwrap()) +} + +fn wastret2tinywasmvalues(ret: wast::WastRet) -> Result<Vec<WasmValue>> { + let wast::WastRet::Core(ret) = ret else { bail!("unsupported arg type") }; + match ret { + wast::core::WastRetCore::Either(options) => { + options.into_iter().map(wastretcore2tinywasmvalue).collect::<Result<Vec<_>>>() + } + ret => Ok(vec![wastretcore2tinywasmvalue(ret)?]), + } +} + +fn wastretcore2tinywasmvalue(ret: wast::core::WastRetCore) -> Result<WasmValue> { + use wast::core::WastRetCore::{F32, F64, I32, I64, RefExtern, RefFunc, RefNull, V128}; + Ok(match ret { + F32(f) => nanpattern2tinywasmvalue(f)?, + F64(f) => nanpattern2tinywasmvalue(f)?, + I32(i) => WasmValue::I32(i), + I64(i) => WasmValue::I64(i), + V128(i) => WasmValue::V128(wast_i128_to_i128(i)), + RefNull(t) => match t { + Some(wast::core::HeapType::Abstract { shared: false, ty: AbstractHeapType::Func }) => { + WasmValue::RefFunc(FuncRef::null()) + } + Some(wast::core::HeapType::Abstract { shared: false, ty: AbstractHeapType::Extern }) => { + WasmValue::RefExtern(ExternRef::null()) + } + _ => bail!("unsupported arg type: refnull: {:?}", t), + }, + RefExtern(v) => WasmValue::RefExtern(ExternRef::new(v)), + RefFunc(v) => WasmValue::RefFunc(FuncRef::new(match v { + Some(wast::token::Index::Num(n, _)) => Some(n), + _ => bail!("unsupported arg type: reffunc: {:?}", v), + })), + a => bail!("unsupported arg type {:?}", a), + }) +} + +enum Bits { + U32(u32), + U64(u64), +} + +trait FloatToken { + fn bits(&self) -> Bits; + fn canonical_nan() -> WasmValue; + fn arithmetic_nan() -> WasmValue; + fn value(&self) -> WasmValue { + match self.bits() { + Bits::U32(v) => WasmValue::F32(f32::from_bits(v)), + Bits::U64(v) => WasmValue::F64(f64::from_bits(v)), + } + } +} + +impl FloatToken for wast::token::F32 { + fn bits(&self) -> Bits { + Bits::U32(self.bits) + } + fn canonical_nan() -> WasmValue { + WasmValue::F32(f32::NAN) + } + fn arithmetic_nan() -> WasmValue { + WasmValue::F32(f32::NAN) + } +} + +impl FloatToken for wast::token::F64 { + fn bits(&self) -> Bits { + Bits::U64(self.bits) + } + fn canonical_nan() -> WasmValue { + WasmValue::F64(f64::NAN) + } + fn arithmetic_nan() -> WasmValue { + WasmValue::F64(f64::NAN) + } +} + +fn nanpattern2tinywasmvalue<T>(arg: wast::core::NanPattern<T>) -> Result<WasmValue> +where + T: FloatToken, +{ + use wast::core::NanPattern::{ArithmeticNan, CanonicalNan, Value}; + Ok(match arg { + CanonicalNan => T::canonical_nan(), + ArithmeticNan => T::arithmetic_nan(), + Value(v) => v.value(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runs_simple_wast_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("simple.wast"); + std::fs::write( + &path, + "(module (func (export \"add\") (result i32) i32.const 1))\n(assert_return (invoke \"add\") (i32.const 1))", + ) + .unwrap(); + + let mut runner = WastRunner::new(); + runner.run_paths(&[path]).unwrap(); + } +} diff --git a/crates/cli/src/wat.rs b/crates/cli/src/wat.rs deleted file mode 100644 index 8d2998d..0000000 --- a/crates/cli/src/wat.rs +++ /dev/null @@ -1,10 +0,0 @@ -use wast::{ - Wat, - parser::{self, ParseBuffer}, -}; - -pub fn wat2wasm(wat: &str) -> Vec<u8> { - let buf = ParseBuffer::new(wat).expect("failed to create parse buffer"); - let mut module = parser::parse::<Wat>(&buf).expect("failed to parse wat"); - module.encode().expect("failed to encode wat") -} diff --git a/crates/cli/tests/cli.rs b/crates/cli/tests/cli.rs new file mode 100644 index 0000000..b7d303e --- /dev/null +++ b/crates/cli/tests/cli.rs @@ -0,0 +1,137 @@ +use std::fs; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::tempdir; + +fn write_module(dir: &tempfile::TempDir, name: &str, source: &str) -> String { + let path = dir.path().join(name); + fs::write(&path, source).unwrap(); + path.to_string_lossy().into_owned() +} + +#[test] +fn run_invoke_accepts_positional_args() { + let dir = tempdir().unwrap(); + let module = write_module( + &dir, + "add.wat", + r#"(module + (func (export "add") (param i32 i32) (result i32) + local.get 0 + local.get 1 + i32.add))"#, + ); + + Command::cargo_bin("tinywasm") + .unwrap() + .args(["run", "--invoke", "add", &module, "1", "2"]) + .assert() + .success() + .stdout(predicate::str::contains("i32(3)")); +} + +#[test] +fn compile_and_run_twasm() { + let dir = tempdir().unwrap(); + let input = write_module( + &dir, + "add.wat", + r#"(module + (func (export "add") (param i32 i32) (result i32) + local.get 0 + local.get 1 + i32.add))"#, + ); + let output = dir.path().join("add.twasm"); + + Command::cargo_bin("tinywasm") + .unwrap() + .args(["compile", &input, "-o", output.to_str().unwrap()]) + .assert() + .success(); + + Command::cargo_bin("tinywasm") + .unwrap() + .args(["run", "--invoke", "add", output.to_str().unwrap(), "3", "4"]) + .assert() + .success() + .stdout(predicate::str::contains("i32(7)")); +} + +#[test] +fn bare_run_requires_default_entrypoint() { + let dir = tempdir().unwrap(); + let module = write_module(&dir, "add.wat", r#"(module (func (export "add") (result i32) i32.const 1))"#); + + Command::cargo_bin("tinywasm") + .unwrap() + .arg(&module) + .assert() + .failure() + .stderr(predicate::str::contains("no start function or `_start` export")); +} + +#[test] +fn inspect_lists_exports() { + let dir = tempdir().unwrap(); + let module = write_module( + &dir, + "inspect.wat", + r#"(module + (memory (export "memory") 1) + (func (export "answer") (result i32) i32.const 42))"#, + ); + + Command::cargo_bin("tinywasm").unwrap().args(["inspect", &module]).assert().success().stdout( + predicate::str::contains("answer: func () -> (i32)").and(predicate::str::contains("memory: memory[i32]")), + ); +} + +#[test] +fn dump_prints_lowered_instructions() { + let dir = tempdir().unwrap(); + let module = write_module(&dir, "dump.wat", r#"(module (func (export "noop")))"#); + + Command::cargo_bin("tinywasm") + .unwrap() + .args(["dump", &module]) + .assert() + .success() + .stdout(predicate::str::contains("func[0]").and(predicate::str::contains("0000:"))); +} + +#[test] +fn run_accepts_wat_from_stdin() { + Command::cargo_bin("tinywasm") + .unwrap() + .args(["run", "--invoke", "add", "-", "8", "9"]) + .write_stdin( + r#"(module + (func (export "add") (param i32 i32) (result i32) + local.get 0 + local.get 1 + i32.add))"#, + ) + .assert() + .success() + .stdout(predicate::str::contains("i32(17)")); +} + +#[test] +fn wast_command_runs_simple_spec_script() { + let dir = tempdir().unwrap(); + let path = dir.path().join("simple.wast"); + fs::write( + &path, + "(module (func (export \"add\") (result i32) i32.const 1))\n(assert_return (invoke \"add\") (i32.const 1))", + ) + .unwrap(); + + Command::cargo_bin("tinywasm") + .unwrap() + .args(["wast", path.to_str().unwrap()]) + .assert() + .success() + .stdout(predicate::str::contains("Tests Passed:")); +} |
