From 463a3a6f56a5ca06cda890fd6fe7f16485ef70a0 Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 26 Apr 2026 19:44:34 +0200 Subject: feat: new tinywasm cli Signed-off-by: Henry --- .cargo/config.toml | 1 + CHANGELOG.md | 1 + Cargo.lock | 473 ++++++++++++++++- crates/cli/Cargo.toml | 29 +- crates/cli/README.md | 27 +- crates/cli/src/args.rs | 37 -- crates/cli/src/bin.rs | 119 +---- crates/cli/src/cli.rs | 128 +++++ crates/cli/src/cmd/compile.rs | 15 + crates/cli/src/cmd/completion.rs | 12 + crates/cli/src/cmd/dump.rs | 71 +++ crates/cli/src/cmd/inspect.rs | 35 ++ crates/cli/src/cmd/mod.rs | 7 + crates/cli/src/cmd/run.rs | 47 ++ crates/cli/src/cmd/wast.rs | 12 + crates/cli/src/engine_flags.rs | 154 ++++++ crates/cli/src/lib.rs | 34 ++ crates/cli/src/load.rs | 103 ++++ crates/cli/src/output.rs | 85 +++ crates/cli/src/util.rs | 1 - crates/cli/src/value_parse.rs | 55 ++ crates/cli/src/wast_runner.rs | 885 ++++++++++++++++++++++++++++++++ crates/cli/src/wat.rs | 10 - crates/cli/tests/cli.rs | 137 +++++ crates/tinywasm/Cargo.toml | 1 + crates/tinywasm/src/imports.rs | 4 +- crates/tinywasm/tests/test-wast.rs | 11 +- crates/tinywasm/tests/testsuite/mod.rs | 185 +++---- crates/tinywasm/tests/testsuite/run.rs | 547 -------------------- crates/tinywasm/tests/testsuite/util.rs | 275 ---------- examples/dump-bytecode.rs | 53 -- 31 files changed, 2364 insertions(+), 1190 deletions(-) delete mode 100644 crates/cli/src/args.rs create mode 100644 crates/cli/src/cli.rs create mode 100644 crates/cli/src/cmd/compile.rs create mode 100644 crates/cli/src/cmd/completion.rs create mode 100644 crates/cli/src/cmd/dump.rs create mode 100644 crates/cli/src/cmd/inspect.rs create mode 100644 crates/cli/src/cmd/mod.rs create mode 100644 crates/cli/src/cmd/run.rs create mode 100644 crates/cli/src/cmd/wast.rs create mode 100644 crates/cli/src/engine_flags.rs create mode 100644 crates/cli/src/lib.rs create mode 100644 crates/cli/src/load.rs create mode 100644 crates/cli/src/output.rs delete mode 100644 crates/cli/src/util.rs create mode 100644 crates/cli/src/value_parse.rs create mode 100644 crates/cli/src/wast_runner.rs delete mode 100644 crates/cli/src/wat.rs create mode 100644 crates/cli/tests/cli.rs delete mode 100644 crates/tinywasm/tests/testsuite/run.rs delete mode 100644 crates/tinywasm/tests/testsuite/util.rs delete mode 100644 examples/dump-bytecode.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 0190999..122d4ae 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,6 +5,7 @@ test-wasm-2="test --package tinywasm --test test-wasm-2 --release" test-wasm-3="test --package tinywasm --test test-wasm-3 --release" test-wast="test --package tinywasm --test test-wast" test-wasm-custom="test --package tinywasm --test test-wasm-custom --release" +cli="run -p tinywasm-cli --bin tinywasm --release --" [target.x86_64-unknown-linux-gnu] rustflags=["-C", "target-cpu=x86-64-v3"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ddae8f..76bf8fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ This release is a major runtime and API rework. It adds support for several newe - `engine::Config` support for fuel policy, stack sizing, memory backend selection, and trap-on-OOM behavior - New feature flags: `canonicalize-nans`, `simd-x86`, `guest-debug`, `debug`, and `parallel-parser` - Top-level parser re-exports behind the `parser` feature: `parse_bytes`, `parse_file`, and `parse_stream` +- Completely new `tinywasm` cli ### Changed diff --git a/Cargo.lock b/Cargo.lock index 0ee7915..43cc6a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,21 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" @@ -33,34 +48,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] -name = "argh" -version = "0.1.19" +name = "anstyle-parse" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "211818e820cda9ca6f167a64a5c808837366a6dfd807157c64c1304c486cd033" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ - "argh_derive", - "argh_shared", + "utf8parse", ] [[package]] -name = "argh_derive" -version = "0.1.19" +name = "anstyle-query" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c442a9d18cef5dde467405d27d461d080d68972d6d0dfd0408265b6749ec427d" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "argh_shared", - "proc-macro2", - "quote", - "syn", + "windows-sys", ] [[package]] -name = "argh_shared" -version = "0.1.19" +name = "anstyle-wincon" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ade012bac4db278517a0132c8c10c6427025868dca16c801087c28d5a411f1" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ - "serde", + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert_cmd" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", ] [[package]] @@ -75,6 +109,17 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -137,6 +182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -145,8 +191,31 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -164,6 +233,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "criterion" version = "0.8.2" @@ -229,6 +304,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "either" version = "1.15.0" @@ -266,6 +347,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "eyre" version = "0.6.12" @@ -276,12 +367,46 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "half" version = "2.7.1" @@ -293,12 +418,27 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -311,6 +451,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "include_dir" version = "0.7.4" @@ -343,7 +489,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] @@ -357,6 +505,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -390,6 +544,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" @@ -402,6 +562,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num-traits" version = "0.2.19" @@ -417,6 +583,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -451,6 +623,36 @@ dependencies = [ "serde", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_env_logger" version = "0.5.0" @@ -461,6 +663,16 @@ dependencies = [ "log", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -479,6 +691,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rayon" version = "1.12.0" @@ -528,6 +746,19 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "same-file" version = "1.0.6" @@ -592,6 +823,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.117" @@ -603,6 +840,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -612,6 +862,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.18" @@ -655,6 +911,7 @@ dependencies = [ "pretty_env_logger", "serde", "serde_json", + "tinywasm-cli", "tinywasm-parser", "tinywasm-types", "wasm-testsuite", @@ -666,12 +923,19 @@ dependencies = [ name = "tinywasm-cli" version = "0.9.0-alpha.0" dependencies = [ - "argh", + "anstream", + "assert_cmd", + "clap", + "clap_complete", "eyre", "log", + "owo-colors", + "predicates", "pretty_env_logger", + "tempfile", "tinywasm", "wast", + "wat", ] [[package]] @@ -680,7 +944,7 @@ version = "0.9.0-alpha.0" dependencies = [ "log", "tinywasm-types", - "wasmparser", + "wasmparser 0.247.0", "wat", ] @@ -715,6 +979,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -725,6 +1010,34 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + [[package]] name = "wasm-encoder" version = "0.247.0" @@ -732,7 +1045,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.247.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] @@ -745,6 +1070,18 @@ dependencies = [ "wast", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "wasmparser" version = "0.247.0" @@ -766,7 +1103,7 @@ dependencies = [ "leb128fmt", "memchr", "unicode-width", - "wasm-encoder", + "wasm-encoder 0.247.0", ] [[package]] @@ -824,6 +1161,100 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata", + "wasmparser 0.244.0", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + [[package]] name = "zerocopy" version = "0.8.48" 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) -> Vec { - args.into_iter().map(Into::into).collect() -} - -impl From 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 { - let [ty, val]: [&str; 2] = s - .split(':') - .collect::>() - .try_into() - .map_err(|_e| "invalid argument format; expected type:value".to_string())?; - - let arg: WasmValue = match ty { - "i32" => val.parse::().map_err(|e| format!("invalid argument value for i32: {e:?}"))?.into(), - "i64" => val.parse::().map_err(|e| format!("invalid argument value for i64: {e:?}"))?.into(), - "f32" => val.parse::().map_err(|e| format!("invalid argument value for f32: {e:?}"))?.into(), - "f64" => val.parse::().map_err(|e| format!("invalid argument value for f64: {e:?}"))?.into(), - "v128" => val.parse::().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 { - 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, - - /// arguments passed to the function in type:value form - #[argh(option, short = 'a')] - args: Vec, - - /// 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, 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, + + #[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, + + /// Invoke a named export instead of the default entrypoint + #[arg(long)] + pub invoke: Option, + + #[command(flatten)] + pub engine: EngineFlags, + + /// Arguments passed to the invoked Wasm function + #[arg(trailing_var_arg = true)] + pub args: Vec, +} + +#[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, + + /// 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, +} + +#[derive(Clone, Copy, ValueEnum)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl From 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::>(); + + 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 {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::>(); + 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, + + /// 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, + + /// 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, + + /// 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, + + /// Fixed call stack size in frames + #[arg(long, conflicts_with = "call_stack_dynamic")] + pub call_stack_size: Option, + + /// Dynamic call stack config in initial:max form + #[arg(long, value_name = "INITIAL:MAX", conflicts_with = "call_stack_size")] + pub call_stack_dynamic: Option, +} + +#[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 { + let (initial, max) = s.split_once(':').ok_or_else(|| "expected INITIAL:MAX".to_string())?; + let initial = initial.parse::().map_err(|e| format!("invalid initial stack size: {e}"))?; + let max = max.parse::().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 { + 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 { + let bytes = read_input_bytes(input)?; + load_module_from_bytes(input, &bytes) +} + +pub fn load_compilable_module(input: &str) -> Result { + 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 { + 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 { + 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> { + 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::>().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::>().join(", "); + let results = ty.results().iter().map(|ty| format_wasm_type(*ty)).collect::>().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> { + 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 { + let parsed = match ty { + WasmType::I32 => value.parse::().map(WasmValue::from).map_err(|e| format_error(index, ty, value, e))?, + WasmType::I64 => value.parse::().map(WasmValue::from).map_err(|e| format_error(index, ty, value, e))?, + WasmType::F32 => value.parse::().map(WasmValue::from).map_err(|e| format_error(index, ty, value, e))?, + WasmType::F64 => value.parse::().map(WasmValue::from).map_err(|e| format_error(index, ty, value, e))?, + WasmType::V128 => value.parse::().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, + named_modules: HashMap, + last_module: Option, +} + +impl ModuleRegistry { + fn modules(&self) -> &HashMap { + &self.modules + } + + fn update_last_module(&mut self, module: ModuleInstance, name: Option) { + 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>) -> Option { + 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>) -> Option { + 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 { + self.last_module.clone() + } +} + +#[derive(Default)] +pub struct WastRunner(BTreeMap); + +#[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 { + 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>) -> Result<()> { + for file in tests { + self.run_file(file)?; + } + Ok(()) + } + + fn imports(store: &mut Store, modules: &HashMap) -> Result { + 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, _> = 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, _> = 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, _> = 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, + 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> { + 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> { + 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>> { + Ok(wast::parser::parse::>(&self.buffer)?.directives) + } +} + +fn exec_with_budget( + func: &tinywasm::Function, + store: &mut Store, + args: &[WasmValue], +) -> Result, 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) -> String { + let info = panic.downcast_ref::().map(ToString::to_string); + let info_string = panic.downcast_ref::().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, + store: &mut Store, + name: &str, + args: &[WasmValue], +) -> Result, 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(f: impl FnOnce() -> R) -> std::thread::Result { + 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, Vec) { + 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::>() + .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::(&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 { + Ok(tinywasm::parse_bytes(bytes)?) +} + +fn convert_wastargs(args: Vec) -> Result> { + args.into_iter().map(wastarg2tinywasmvalue).collect() +} + +fn convert_wastret<'a>(args: impl Iterator>) -> Result>> { + 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 { + 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 = 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> { + let wast::WastRet::Core(ret) = ret else { bail!("unsupported arg type") }; + match ret { + wast::core::WastRetCore::Either(options) => { + options.into_iter().map(wastretcore2tinywasmvalue).collect::>>() + } + ret => Ok(vec![wastretcore2tinywasmvalue(ret)?]), + } +} + +fn wastretcore2tinywasmvalue(ret: wast::core::WastRetCore) -> Result { + 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(arg: wast::core::NanPattern) -> Result +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 { - let buf = ParseBuffer::new(wat).expect("failed to create parse buffer"); - let mut module = parser::parse::(&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:")); +} diff --git a/crates/tinywasm/Cargo.toml b/crates/tinywasm/Cargo.toml index 2f80695..15ae832 100644 --- a/crates/tinywasm/Cargo.toml +++ b/crates/tinywasm/Cargo.toml @@ -27,6 +27,7 @@ libm={version="0.2", default-features=false} [dev-dependencies] wasm-testsuite.workspace=true +tinywasm-cli={version="0.9.0-alpha.0", path="../cli", features=["wast"]} indexmap.workspace=true wast.workspace=true wat.workspace=true diff --git a/crates/tinywasm/src/imports.rs b/crates/tinywasm/src/imports.rs index 8198b55..e9f1029 100644 --- a/crates/tinywasm/src/imports.rs +++ b/crates/tinywasm/src/imports.rs @@ -4,7 +4,7 @@ use alloc::vec::Vec; use core::fmt::Debug; use core::hint::cold_path; -use crate::{Function, Global, LinkingError, Memory, Result, Table, log}; +use crate::{Function, Global, LinkingError, Memory, Result, Table}; use tinywasm_types::*; #[derive(Clone)] @@ -147,7 +147,6 @@ impl Imports { fn compare_types(import: &Import, actual: &T, expected: &T) -> Result<()> { if expected != actual { cold_path(); - log::error!("failed to link import {}", import.name); return Err(LinkingError::incompatible_import_type(import).into()); } Ok(()) @@ -157,7 +156,6 @@ impl Imports { fn compare_types(import: &Import, actual: &T, expected: &T) -> Result<()> { if expected != actual { cold_path(); - log::error!("failed to link import {}: expected {:?}, got {:?}", import.name, expected, actual); return Err(LinkingError::incompatible_import_type(import).into()); } Ok(()) diff --git a/crates/tinywasm/tests/test-wast.rs b/crates/tinywasm/tests/test-wast.rs index ff2b2ed..663a5fb 100644 --- a/crates/tinywasm/tests/test-wast.rs +++ b/crates/tinywasm/tests/test-wast.rs @@ -1,9 +1,7 @@ use std::path::PathBuf; use eyre::{Result, bail}; -use testsuite::TestSuite; - -mod testsuite; +use tinywasm_cli::wast_runner::WastRunner; fn main() -> Result<()> { let args = std::env::args().collect::>(); @@ -27,9 +25,6 @@ fn main() -> Result<()> { vec![arg] }; - TestSuite::set_log_level(log::LevelFilter::Debug); - let mut test_suite = TestSuite::new(); - test_suite.run_paths(&files)?; - test_suite.print_errors(); - test_suite.report_status() + let mut test_suite = WastRunner::new(); + test_suite.run_paths(&files) } diff --git a/crates/tinywasm/tests/testsuite/mod.rs b/crates/tinywasm/tests/testsuite/mod.rs index 0221c6a..ea44c29 100644 --- a/crates/tinywasm/tests/testsuite/mod.rs +++ b/crates/tinywasm/tests/testsuite/mod.rs @@ -1,19 +1,11 @@ -#![allow(unused)] +#![allow(dead_code)] + use eyre::{Result, eyre}; -use indexmap::IndexMap; use owo_colors::OwoColorize; -use std::fmt::Display; -use std::io::{BufRead, Seek, SeekFrom}; -use std::{ - collections::BTreeMap, - fmt::{Debug, Formatter}, - io::BufReader, -}; - -mod run; -mod util; - use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; +use std::io::{BufRead, BufReader, Seek, SeekFrom}; +use tinywasm_cli::wast_runner::{GroupResult, TestFile as RunnerTestFile, WastRunner}; #[derive(Serialize, Deserialize)] pub struct TestGroupResult { @@ -22,56 +14,74 @@ pub struct TestGroupResult { pub failed: usize, } -pub struct TestSuite(BTreeMap, Vec); +pub struct TestSuite { + runner: WastRunner, +} impl TestSuite { pub fn set_log_level(level: log::LevelFilter) { - pretty_env_logger::formatted_builder().filter_level(level).init(); + WastRunner::set_log_level(level); } - pub fn report_status(&self) -> Result<()> { - if self.failed() { - println!(); - Err(eyre!(format!("{}:\n{self}", "failed one or more tests".red().bold()))) - } else { - println!("\n\npassed all tests:\n{self}"); - Ok(()) - } + pub fn new() -> Self { + Self { runner: WastRunner::new() } } - pub fn print_errors(&self) { - for (group_name, group) in &self.0 { - let tests = &group.tests; - for (test_name, test) in tests { - if let Err(e) = &test.result { - eprintln!( - "{} {} failed: {:?}", - link(group_name, &group.file, Some(test.linecol.0 + 1)).bold().underline(), - test_name.bold(), - e.to_string().bright_red() - ); + pub fn run_paths(&mut self, tests: &[std::path::PathBuf]) -> Result<()> { + let mut files = Vec::new(); + for path in tests { + 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(); + + let runner_files = files + .iter() + .map(|path| { + let contents = std::fs::read_to_string(path)?; + let name = path.to_string_lossy().into_owned(); + Ok((name, contents)) + }) + .collect::>>()?; + + self.runner.run_files(runner_files.iter().map(|(name, contents)| RunnerTestFile { + name: name.clone(), + parent: name.clone(), + contents, + })) } - pub fn new() -> Self { - Self(BTreeMap::new(), Vec::new()) - } - - pub fn skip(&mut self, name: &str) { - self.1.push(name.to_string()); + pub fn run_files<'a>(&mut self, tests: impl IntoIterator>) -> Result<()> { + self.runner.run_files(tests.into_iter().map(|file| RunnerTestFile { + name: file.name().to_string(), + parent: file.parent().to_string(), + contents: file.raw(), + })) } - pub fn failed(&self) -> bool { - self.0.values().any(|group| group.stats().1 > 0) + pub fn print_errors(&self) { + self.runner.print_errors(); } - 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 report_status(&self) -> Result<()> { + if self.runner.failed() { + println!(); + Err(eyre!(format!("{}:\n{self}", "failed one or more tests".red().bold()))) + } else { + println!("{self}"); + Ok(()) + } } - // create or add to a test result file pub fn save_csv(&self, path: &str, version: &str) -> Result<()> { use std::fs::OpenOptions; use std::io::Write; @@ -79,45 +89,40 @@ impl TestSuite { let mut file = OpenOptions::new().create(true).append(true).read(true).open(path)?; let last_line = BufReader::new(&file).lines().last().transpose()?; - // Check if the last line starts with the current commit if let Some(last) = last_line && last.starts_with(version) { - // Truncate the file size to remove the last line let len_to_truncate = last.len() as i64; file.set_len(file.metadata()?.len() - len_to_truncate as u64 - 1)?; } - // Seek to the end of the file for appending file.seek(SeekFrom::End(0))?; let mut passed = 0; let mut failed = 0; - let mut groups = Vec::new(); - for (name, group) in &self.0 { - let (group_passed, group_failed) = group.stats(); - passed += group_passed; - failed += group_failed; - groups.push(TestGroupResult { name: name.to_string(), passed: group_passed, failed: group_failed }); + for group in self.runner.group_results() { + passed += group.passed; + failed += group.failed; + groups.push(TestGroupResult { name: group.name, passed: group.passed, failed: group.failed }); } let groups = serde_json::to_string(&groups)?; let line = format!("{version},{passed},{failed},{groups}\n"); - file.write_all(line.as_bytes()).expect("failed to write to csv file"); - + file.write_all(line.as_bytes())?; Ok(()) } -} -fn link(name: &str, file: &str, line: Option) -> String { - let (path, name) = match line { - None => (file.to_string(), name.to_owned()), - Some(line) => (format!("{file}:{line}:0"), (format!("{name}:{line}"))), - }; + fn group_results(&self) -> Vec { + self.runner.group_results() + } +} - format!("\x1b]8;;file://{path}\x1b\\{name}\x1b]8;;\x1b\\") +impl Default for TestSuite { + fn default() -> Self { + Self::new() + } } impl Display for TestSuite { @@ -125,59 +130,21 @@ impl Display for TestSuite { let mut total_passed = 0; let mut total_failed = 0; - for (group_name, group) in &self.0 { - let (group_passed, group_failed) = group.stats(); - total_passed += group_passed; - total_failed += group_failed; + for group in self.group_results() { + total_passed += group.passed; + total_failed += group.failed; - writeln!(f, "{}", link(group_name, &group.file, None).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, "{}", 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 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: IndexMap, - file: String, -} - -impl TestGroup { - fn new(file: &str) -> Self { - Self { tests: IndexMap::new(), file: file.to_string() } - } - - fn stats(&self) -> (usize, usize) { - let mut passed_count = 0; - let mut failed_count = 0; - - for test in self.tests.values() { - match test.result { - Ok(()) => passed_count += 1, - Err(_) => failed_count += 1, - } - } - - (passed_count, failed_count) - } - - fn add_result(&mut self, name: &str, linecol: (usize, usize), result: Result<()>) { - self.tests.insert(name.to_string(), TestCase { result, linecol }); - } -} - -#[derive(Debug)] -struct TestCase { - result: Result<()>, - linecol: (usize, usize), -} diff --git a/crates/tinywasm/tests/testsuite/run.rs b/crates/tinywasm/tests/testsuite/run.rs deleted file mode 100644 index 37a408f..0000000 --- a/crates/tinywasm/tests/testsuite/run.rs +++ /dev/null @@ -1,547 +0,0 @@ -use crate::testsuite::util::*; -use std::{borrow::Cow, collections::HashMap, fs::canonicalize, path::PathBuf}; - -use super::TestSuite; -use eyre::{Result, eyre}; -use indexmap::IndexMap; -use log::{debug, error, info}; -use tinywasm::{Global, HostFunction, Imports, Memory, ModuleInstance, Table}; -use tinywasm_types::{ExternVal, MemoryType, ModuleInstanceAddr, TableType, WasmType, WasmValue}; -use wasm_testsuite::data::TestFile; -use wasm_testsuite::wast; -use wasm_testsuite::wast::{Wast, lexer::Lexer, parser::ParseBuffer}; - -#[derive(Default)] -struct ModuleRegistry { - modules: HashMap, - - named_modules: HashMap, - last_module: Option, -} - -impl ModuleRegistry { - fn modules(&self) -> &HashMap { - &self.modules - } - - fn update_last_module(&mut self, module: ModuleInstance, name: Option) { - 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) { - log::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>) -> Option { - match module_id { - Some(module) => { - log::debug!("getting module: {}", module.name()); - - if let Some(module) = self.modules.get(module.name()) { - return Some(module.id()); - } - - if let Some(module) = self.named_modules.get(module.name()) { - return Some(module.id()); - } - - None - } - None => self.last_module.as_ref().map(ModuleInstance::id), - } - } - - fn get(&self, module_id: Option>) -> Option { - 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 { - self.last_module.clone() - } -} - -impl TestSuite { - pub fn run_paths(&mut self, tests: &[PathBuf]) -> Result<()> { - for file_name in tests { - let group_wast = std::fs::read(file_name).expect("failed to read test wast"); - let file = TestFile { - contents: std::str::from_utf8(&group_wast).expect("failed to convert to utf8"), - name: canonicalize(file_name).expect("failed to canonicalize file name").to_string_lossy().to_string(), - parent: "(custom group)".into(), - }; - - self.run_file(file).expect("failed to run group"); - } - - Ok(()) - } - - fn imports(store: &mut tinywasm::Store, modules: &HashMap) -> Result { - let mut imports = Imports::new(); - - let table = Table::new( - store, - TableType::new(WasmType::RefFunc, 10, Some(20)), - WasmValue::default_for(WasmType::RefFunc), - )?; - - let print = HostFunction::from(store, |_ctx: tinywasm::FuncContext, (): ()| { - log::debug!("print"); - Ok(()) - }); - - let print_i32 = HostFunction::from(store, |_ctx: tinywasm::FuncContext, arg: i32| { - log::debug!("print_i32: {arg}"); - Ok(()) - }); - - let print_i64 = HostFunction::from(store, |_ctx: tinywasm::FuncContext, arg: i64| { - log::debug!("print_i64: {arg}"); - Ok(()) - }); - - let print_f32 = HostFunction::from(store, |_ctx: tinywasm::FuncContext, arg: f32| { - log::debug!("print_f32: {arg}"); - Ok(()) - }); - - let print_f64 = HostFunction::from(store, |_ctx: tinywasm::FuncContext, arg: f64| { - log::debug!("print_f64: {arg}"); - Ok(()) - }); - - let print_i32_f32 = HostFunction::from(store, |_ctx: tinywasm::FuncContext, args: (i32, f32)| { - log::debug!("print_i32_f32: {}, {}", args.0, args.1); - Ok(()) - }); - - let print_f64_f64 = HostFunction::from(store, |_ctx: tinywasm::FuncContext, args: (f64, f64)| { - log::debug!("print_f64_f64: {}, {}", args.0, args.1); - Ok(()) - }); - - 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", print) - .define("spectest", "print_i32", print_i32) - .define("spectest", "print_i64", print_i64) - .define("spectest", "print_f32", print_f32) - .define("spectest", "print_f64", print_f64) - .define("spectest", "print_i32_f32", print_i32_f32) - .define("spectest", "print_f64_f64", print_f64_f64); - - for (name, module) in modules { - log::debug!("registering module: {name}"); - imports.link_module(name, module.clone())?; - } - - Ok(imports) - } - - pub fn run_files<'a>(&mut self, tests: impl IntoIterator>) -> Result<()> { - tests.into_iter().for_each(|group| { - let name = group.name(); - println!("running group: {name}"); - if self.1.contains(&name.to_string()) { - info!("skipping group: {name}"); - self.test_group(&format!("{name} (skipped)"), name); - return; - } - - self.run_file(group).expect("failed to run group"); - }); - - Ok(()) - } - - 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 = tinywasm::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) => { - debug!("got 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(module) = 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(&module)) - .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(_) => { - // - skip "zero byte expected" as the magic number is not checked by wasmparser - // (Don't need to error on this, doesn't matter if it's malformed) - // - skip "integer representation too long" as this has some false positives on older tests - 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).expect("failed to convert 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, _> = 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).expect("failed to convert args"); - 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, _> = 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 } => { - info!("AssertReturn: {exec:?}"); - 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 module = module_registry.get(module_id); - let Some(module) = module 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 match module.export_addr(global) { - Some(ExternVal::Global(addr)) => Ok(store.get_global_val(addr)), - _ => Err(eyre!("no module to get global from")), - } { - Ok(module_global) => module_global, - 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.attach_type(WasmType::from(*exp)).unwrap().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; - // check if module_global matches the expected results - } - 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, _> = catch_unwind_silent(|| { - debug!("invoke: {invoke:?}"); - 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!( - "span: {:?} expected {} results, got {}", - span, - 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(()) - } -} diff --git a/crates/tinywasm/tests/testsuite/util.rs b/crates/tinywasm/tests/testsuite/util.rs deleted file mode 100644 index 1714543..0000000 --- a/crates/tinywasm/tests/testsuite/util.rs +++ /dev/null @@ -1,275 +0,0 @@ -use std::panic::{self, AssertUnwindSafe}; -use std::time::Duration; - -use eyre::{Result, bail, eyre}; -use tinywasm::{ExecProgress, ModuleInstance}; -use tinywasm_types::{ExternRef, FuncRef, Module, ModuleInstanceAddr, WasmType, WasmValue}; -use wasm_testsuite::wast; -use wasm_testsuite::wast::{QuoteWat, core::AbstractHeapType}; - -const TEST_TIME_SLICE: Duration = Duration::from_millis(20); -const TEST_MAX_SUSPENSIONS: u32 = 1000; - -fn exec_with_budget( - func: &tinywasm::Function, - store: &mut tinywasm::Store, - args: &[tinywasm_types::WasmValue], -) -> Result, 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 - ))) -} - -pub fn try_downcast_panic(panic: Box) -> String { - let info = panic.downcast_ref::().or(None).map(ToString::to_string).clone(); - let info_string = panic.downcast_ref::().cloned(); - let info_str = panic.downcast::<&str>().ok().map(|s| *s); - info.unwrap_or(info_str.unwrap_or(&info_string.unwrap_or("unknown panic".to_owned())).to_string()) -} - -pub fn exec_fn_instance( - instance: Option, - store: &mut tinywasm::Store, - name: &str, - args: &[tinywasm_types::WasmValue], -) -> Result, 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) -} - -pub fn exec_fn( - module: Option<&Module>, - name: &str, - args: &[tinywasm_types::WasmValue], - imports: Option, -) -> Result, tinywasm::Error> { - let Some(module) = module else { - return Err(tinywasm::Error::Other("no module found".to_string())); - }; - - let mut store = tinywasm::Store::default(); - let instance = ModuleInstance::instantiate(&mut store, module, imports)?; - let func = instance.func_untyped(&store, name)?; - exec_with_budget(&func, &mut store, args) -} - -pub fn catch_unwind_silent(f: impl FnOnce() -> R) -> std::thread::Result { - let prev_hook = panic::take_hook(); - panic::set_hook(Box::new(|_| {})); - let result = panic::catch_unwind(AssertUnwindSafe(f)); - panic::set_hook(prev_hook); - result -} - -pub fn encode_quote_wat(module: QuoteWat) -> (Option, Vec) { - 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::>() - .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::(&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"), - } -} - -pub fn parse_module_bytes(bytes: &[u8]) -> Result { - let parser = tinywasm_parser::Parser::new(); - Ok(parser.parse_module_bytes(bytes)?) -} - -pub fn convert_wastargs(args: Vec) -> Result> { - args.into_iter().map(|a| wastarg2tinywasmvalue(a)).collect() -} - -pub fn convert_wastret<'a>( - args: impl Iterator>, -) -> Result>> { - 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 { - 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(v) => bail!("unsupported arg type: RefHost"), - }) -} - -fn wast_i128_to_i128(i: wast::core::V128Pattern) -> i128 { - let res: Vec = 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> { - let wast::WastRet::Core(ret) = ret else { - bail!("unsupported arg type"); - }; - - match ret { - wast::core::WastRetCore::Either(options) => { - options.into_iter().map(wastretcore2tinywasmvalue).collect::>>() - } - ret => Ok(vec![wastretcore2tinywasmvalue(ret)?]), - } -} - -fn wastretcore2tinywasmvalue(ret: wast::core::WastRetCore) -> Result { - 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(arg: wast::core::NanPattern) -> Result -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(), - }) -} diff --git a/examples/dump-bytecode.rs b/examples/dump-bytecode.rs deleted file mode 100644 index 912da41..0000000 --- a/examples/dump-bytecode.rs +++ /dev/null @@ -1,53 +0,0 @@ -use eyre::{Result, bail}; -use std::io::Read; -use std::path::Path; -use tinywasm::parser::Parser; -use tinywasm::types::{ExternalKind, ImportKind}; - -fn read_input(path: &str) -> Result> { - if path == "-" { - let mut source = String::new(); - std::io::stdin().read_to_string(&mut source)?; - return Ok(wat::parse_str(source)?); - } - - let bytes = std::fs::read(path)?; - let is_wasm = Path::new(path).extension().and_then(|s| s.to_str()) == Some("wasm"); - if is_wasm { Ok(bytes) } else { Ok(wat::parse_bytes(&bytes)?.into_owned()) } -} - -fn main() -> Result<()> { - let args = std::env::args().collect::>(); - if args.len() != 2 { - bail!("usage: cargo run --example dump-bytecode -- ") - } - - let wasm = read_input(&args[1])?; - let module = Parser::new().parse_module_bytes(&wasm)?; - - 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::>(); - - if exports.is_empty() { - println!("func[{func_idx}] global={global_idx}"); - } else { - println!("func[{func_idx}] global={global_idx} exports={exports:?}"); - } - - for (ip, instr) in func.instructions.iter().enumerate() { - println!(" {ip:04}: {instr:?}"); - } - println!(); - } - - Ok(()) -} -- cgit v1.3.1