diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 917eb197..4177acae 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -158,6 +158,50 @@ "kind": "build", "isDefault": true } + }, + { + "label": "rust clippy", + "type": "process", + "command": "cargo", + "args": [ + "clippy", + "--all-targets", + "--all-features", + "--", + "-D", + "warnings", + "-A", + "clippy::extra_unused_lifetimes" + ], + "options": { + "cwd": "${workspaceRoot}/native/wasmex" + }, + "problemMatcher": [ + "$mixCompileWarning", + "$mixCompileError" + ], + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "rust format", + "type": "process", + "command": "cargo", + "args": ["fmt"], + "options": { + "cwd": "${workspaceRoot}/native/wasmex" + }, + "problemMatcher": [ + "$mixCompileWarning", + "$mixCompileError" + ], + "group": { + "kind": "build", + "isDefault": true + } } + ] } \ No newline at end of file diff --git a/lib/wasmex/components/component.ex b/lib/wasmex/components/component.ex index 123d6944..77c2fa52 100644 --- a/lib/wasmex/components/component.ex +++ b/lib/wasmex/components/component.ex @@ -31,4 +31,40 @@ defmodule Wasmex.Components.Component do resource -> {:ok, __wrap_resource__(resource)} end end + + defmacro __using__(opts) do + genserver_setup = + quote do + use GenServer + + def start_link(opts) do + Wasmex.Components.start_link(opts) + end + + def handle_call(request, from, state) do + Wasmex.Components.handle_call(request, from, state) + end + end + + functions = + if wit_path = Keyword.get(opts, :wit) do + wit_contents = File.read!(wit_path) + exported_functions = Wasmex.Native.wit_exported_functions(wit_path, wit_contents) + + for {function, arity} <- exported_functions do + arglist = Macro.generate_arguments(arity, __MODULE__) + function_atom = function |> String.replace("-", "_") |> String.to_atom() + + quote do + def unquote(function_atom)(pid, unquote_splicing(arglist)) do + Wasmex.Components.call_function(pid, unquote(function), [unquote_splicing(arglist)]) + end + end + end + else + [] + end + + [genserver_setup, functions] + end end diff --git a/lib/wasmex/native.ex b/lib/wasmex/native.ex index 7bdaa0a3..da171c21 100644 --- a/lib/wasmex/native.ex +++ b/lib/wasmex/native.ex @@ -93,6 +93,8 @@ defmodule Wasmex.Native do def component_call_function(_store, _instance, _function_name, _params), do: error() + def wit_exported_functions(_path, _wit), do: error() + # When the NIF is loaded, it will override functions in this module. # Calling error is handles the case when the nif could not be loaded. defp error, do: :erlang.nif_error(:nif_not_loaded) diff --git a/native/wasmex/Cargo.lock b/native/wasmex/Cargo.lock index 375ab303..0815ecf8 100644 --- a/native/wasmex/Cargo.lock +++ b/native/wasmex/Cargo.lock @@ -233,6 +233,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15" +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1830,6 +1839,12 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.0" @@ -2002,6 +2017,7 @@ dependencies = [ name = "wasmex" version = "0.9.2" dependencies = [ + "convert_case", "once_cell", "rand", "rustler", @@ -2011,6 +2027,7 @@ dependencies = [ "wasmtime-wasi-http", "wat", "wiggle", + "wit-parser 0.221.2", ] [[package]] @@ -2037,6 +2054,17 @@ dependencies = [ "indexmap", ] +[[package]] +name = "wasmparser" +version = "0.221.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9845c470a2e10b61dd42c385839cdd6496363ed63b5c9e420b5488b77bd22083" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + [[package]] name = "wasmprinter" version = "0.218.0" @@ -2146,7 +2174,7 @@ dependencies = [ "syn", "wasmtime-component-util", "wasmtime-wit-bindgen", - "wit-parser", + "wit-parser 0.218.0", ] [[package]] @@ -2343,7 +2371,7 @@ dependencies = [ "anyhow", "heck", "indexmap", - "wit-parser", + "wit-parser 0.218.0", ] [[package]] @@ -2605,6 +2633,24 @@ dependencies = [ "wasmparser 0.218.0", ] +[[package]] +name = "wit-parser" +version = "0.221.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbe1538eea6ea5ddbe5defd0dc82539ad7ba751e1631e9185d24a931f0a5adc8" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.221.2", +] + [[package]] name = "witx" version = "0.9.1" diff --git a/native/wasmex/Cargo.toml b/native/wasmex/Cargo.toml index ae0a3a96..f5292076 100644 --- a/native/wasmex/Cargo.toml +++ b/native/wasmex/Cargo.toml @@ -24,3 +24,5 @@ wasmtime-wasi-http = "26.0.1" wasi-common = "26.0.1" wiggle = "26.0.1" wat = "1.220.0" +wit-parser = "0.221.2" +convert_case = "0.6.0" diff --git a/native/wasmex/src/component_instance.rs b/native/wasmex/src/component_instance.rs index 36e04294..0386fd78 100644 --- a/native/wasmex/src/component_instance.rs +++ b/native/wasmex/src/component_instance.rs @@ -5,6 +5,8 @@ use crate::component::ComponentInstanceResource; use crate::store::ComponentStoreData; use crate::store::ComponentStoreResource; +use convert_case::{Case, Casing}; + use rustler::types::atom::nil; use rustler::types::tuple; use rustler::types::tuple::make_tuple; @@ -63,7 +65,9 @@ pub fn component_call_function<'a>( let _ = function.post_return(&mut *component_store); Ok(encode_result(env, result)) } - Err(err) => Ok(env.error_tuple(format!("Error executing function: {err}"))), + Err(err) => Err(rustler::Error::Term(Box::new(format!( + "Error executing function: {err}" + )))), } } @@ -150,13 +154,13 @@ fn term_to_val(param_term: &Term, param_type: &Type) -> Result { fn term_to_field_name(key_term: &Term) -> String { match key_term.get_type() { - TermType::Atom => key_term.atom_to_string().unwrap(), - _ => key_term.decode::().unwrap(), + TermType::Atom => key_term.atom_to_string().unwrap().to_case(Case::Kebab), + _ => key_term.decode::().unwrap().to_case(Case::Kebab), } } fn field_name_to_term<'a>(env: &rustler::Env<'a>, field_name: &str) -> Term<'a> { - rustler::serde::atoms::str_to_term(env, field_name).unwrap() + rustler::serde::atoms::str_to_term(env, &field_name.to_case(Case::Snake)).unwrap() } fn encode_result(env: rustler::Env, vals: Vec) -> Term { diff --git a/native/wasmex/src/lib.rs b/native/wasmex/src/lib.rs index 57d9bc83..e0fbdefc 100644 --- a/native/wasmex/src/lib.rs +++ b/native/wasmex/src/lib.rs @@ -11,5 +11,6 @@ pub mod module; pub mod pipe; pub mod printable_term_type; pub mod store; +pub mod wit; rustler::init!("Elixir.Wasmex.Native"); diff --git a/native/wasmex/src/wit.rs b/native/wasmex/src/wit.rs new file mode 100644 index 00000000..1cb29757 --- /dev/null +++ b/native/wasmex/src/wit.rs @@ -0,0 +1,22 @@ +use rustler::{NifResult, Term}; +use wit_parser::{Resolve, WorldItem}; + +#[rustler::nif(name = "wit_exported_functions")] +pub fn exported_functions(env: rustler::Env, path: String, wit: String) -> NifResult { + let mut resolve = Resolve::new(); + let id = resolve + .push_str(path, &wit) + .map_err(|e| rustler::Error::Term(Box::new(format!("Failed to parse WIT: {}", e))))?; + let world_id = resolve + .select_world(id, None) + .map_err(|e| rustler::Error::Term(Box::new(format!("Failed to select world: {}", e))))?; + let exports = &resolve.worlds[world_id].exports; + let exported_functions = exports + .iter() + .filter_map(|(_key, value)| match value { + WorldItem::Function(function) => Some((&function.name, function.params.len())), + _ => None, + }) + .collect::>(); + Ok(Term::map_from_pairs(env, exported_functions.as_slice()).unwrap()) +} diff --git a/test/component_fixtures/hello_world.ex b/test/component_fixtures/hello_world.ex new file mode 100644 index 00000000..779a8e5c --- /dev/null +++ b/test/component_fixtures/hello_world.ex @@ -0,0 +1,5 @@ +defmodule HelloWorld do + @moduledoc false + + use Wasmex.Components.Component, wit: "test/component_fixtures/hello_world/hello-world.wit" +end diff --git a/test/component_fixtures/hello_world/build.sh b/test/component_fixtures/hello_world/build.sh new file mode 100755 index 00000000..015c168c --- /dev/null +++ b/test/component_fixtures/hello_world/build.sh @@ -0,0 +1 @@ +npx jco componentize -w hello-world.wit -o hello_world.wasm hello-world.js \ No newline at end of file diff --git a/test/component_fixtures/hello_world/hello-world.js b/test/component_fixtures/hello_world/hello-world.js index 89dff04d..074517a6 100644 --- a/test/component_fixtures/hello_world/hello-world.js +++ b/test/component_fixtures/hello_world/hello-world.js @@ -12,4 +12,8 @@ export function multiGreet(who, times) { export function greetMany(people) { return people.map((person) => `Hello, ${person}!`); +} + +export function echoKebab(kebabRecord) { + return kebabRecord; } \ No newline at end of file diff --git a/test/component_fixtures/hello_world/hello-world.wit b/test/component_fixtures/hello_world/hello-world.wit index 6f1f853d..266317a5 100644 --- a/test/component_fixtures/hello_world/hello-world.wit +++ b/test/component_fixtures/hello_world/hello-world.wit @@ -1,7 +1,11 @@ package local:hello-world; world hello-world { + record kebab-record { + kebab-field: string + } export greet: func(who: string) -> string; export multi-greet: func(who: string, times: u16) -> list; export greet-many: func(people: list) -> list; + export echo-kebab: func(kebab-record: kebab-record) -> kebab-record; } \ No newline at end of file diff --git a/test/component_fixtures/hello_world/hello_world.wasm b/test/component_fixtures/hello_world/hello_world.wasm index 7a78d822..5742c853 100644 Binary files a/test/component_fixtures/hello_world/hello_world.wasm and b/test/component_fixtures/hello_world/hello_world.wasm differ diff --git a/test/components/component_types_test.exs b/test/components/component_types_test.exs index ee06a788..e88f39b7 100644 --- a/test/components/component_types_test.exs +++ b/test/components/component_types_test.exs @@ -1,6 +1,8 @@ defmodule Wasm.Components.ComponentTypesTest do use ExUnit.Case, async: true + alias Wasmex.Wasi.WasiP2Options + setup do {:ok, store} = Wasmex.Components.Store.new() component_bytes = File.read!("test/component_fixtures/component_types/component_types.wasm") @@ -49,6 +51,18 @@ defmodule Wasm.Components.ComponentTypesTest do ]) end + test "record with kebab-field" do + {:ok, store} = Wasmex.Components.Store.new_wasi(%WasiP2Options{}) + component_bytes = File.read!("test/component_fixtures/hello_world/hello_world.wasm") + {:ok, component} = Wasmex.Components.Component.new(store, component_bytes) + {:ok, instance} = Wasmex.Components.Instance.new(store, component) + + assert {:ok, %{kebab_field: "foo"}} = + Wasmex.Components.Instance.call_function(instance, "echo-kebab", [ + %{kebab_field: "foo"} + ]) + end + test "lists", %{instance: instance} do assert {:ok, [1, 2, 3]} = Wasmex.Components.Instance.call_function(instance, "id-list", [[1, 2, 3]]) diff --git a/test/components/components_test.exs b/test/components/components_test.exs index 7063df17..9a59bd30 100644 --- a/test/components/components_test.exs +++ b/test/components/components_test.exs @@ -9,6 +9,18 @@ defmodule Wasmex.ComponentsTest do assert {:error, _error} = Wasmex.Components.call_function(component_pid, "garbage", ["wut"]) end + test "using the component macro" do + component_bytes = File.read!("test/component_fixtures/hello_world/hello_world.wasm") + + component_pid = + start_supervised!({HelloWorld, %{bytes: component_bytes, wasi: %WasiP2Options{}}}) + + assert {:ok, "Hello, Elixir!"} = HelloWorld.greet(component_pid, "Elixir") + + assert {:ok, ["Hello, Elixir!", "Hello, Elixir!"]} = + HelloWorld.multi_greet(component_pid, "Elixir", 2) + end + test "wasi interaction" do component_bytes = File.read!("test/component_fixtures/wasi_p2_test/wasi-p2-test.wasm") diff --git a/test/components/wit_parser_test.exs b/test/components/wit_parser_test.exs new file mode 100644 index 00000000..db621817 --- /dev/null +++ b/test/components/wit_parser_test.exs @@ -0,0 +1,18 @@ +defmodule Components.WitParserTest do + use ExUnit.Case + + describe "exports" do + test "exported_functions" do + wit = File.read!("test/component_fixtures/hello_world/hello-world.wit") + + assert %{"greet" => 1, "greet-many" => 1, "multi-greet" => 2} = + Wasmex.Native.wit_exported_functions("hello-world.wit", wit) + end + + test "wit parse errors" do + wit = "goo goo" + assert {:error, error} = Wasmex.Native.wit_exported_functions("hello-world.wit", wit) + assert error =~ "Failed to parse WIT" + end + end +end