diff --git a/Cargo.toml b/Cargo.toml index b4e32295..983d28b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ exclude = [ "spec/fixtures/component-types", "spec/fixtures/wasi-debug", "spec/fixtures/wasi-deterministic", + "spec/fixtures/host-func-imports", ] [profile.release] diff --git a/ext/src/ruby_api/component/convert.rs b/ext/src/ruby_api/component/convert.rs index 5ecd7984..0f4f9f7f 100644 --- a/ext/src/ruby_api/component/convert.rs +++ b/ext/src/ruby_api/component/convert.rs @@ -25,7 +25,7 @@ define_rb_intern!( pub(crate) fn component_val_to_rb( ruby: &Ruby, val: Val, - _store: &StoreContextValue, + _store: Option<&StoreContextValue>, ) -> Result { match val { Val::Bool(bool) => Ok(bool.into_value_with(ruby)), @@ -103,7 +103,7 @@ pub(crate) fn component_val_to_rb( pub(crate) fn rb_to_component_val( value: Value, - _store: &StoreContextValue, + _store: Option<&StoreContextValue>, ty: &Type, ) -> Result { let ruby = Ruby::get_with(value); diff --git a/ext/src/ruby_api/component/func.rs b/ext/src/ruby_api/component/func.rs index 8b9ee3e1..9e115b68 100644 --- a/ext/src/ruby_api/component/func.rs +++ b/ext/src/ruby_api/component/func.rs @@ -115,12 +115,12 @@ impl Func { 1 => component_val_to_rb( ruby, results.into_iter().next().unwrap(), - &store_context_value, + Some(&store_context_value), ), _ => { let ary = ruby.ary_new_capa(results_ty.len()); for result in results { - let val = component_val_to_rb(ruby, result, &store_context_value)?; + let val = component_val_to_rb(ruby, result, Some(&store_context_value))?; ary.push(val)?; } Ok(ary.into_value_with(ruby)) @@ -152,7 +152,7 @@ fn convert_params<'a>( .try_into() .map_err(|_| Error::new(ruby.exception_arg_error(), "too many params"))?; - let component_val = rb_to_component_val(*value, store, &ty.1) + let component_val = rb_to_component_val(*value, Some(store), &ty.1) .map_err(|error| error.append(format!(" (param at index {i})")))?; params.push(component_val); diff --git a/ext/src/ruby_api/component/linker.rs b/ext/src/ruby_api/component/linker.rs index b0fc9a36..f89616eb 100644 --- a/ext/src/ruby_api/component/linker.rs +++ b/ext/src/ruby_api/component/linker.rs @@ -1,8 +1,9 @@ +use super::convert; use super::{Component, Instance}; use crate::{ err, ruby_api::{ - errors, + errors::{self, ExceptionMessage}, store::{StoreContextValue, StoreData}, Engine, Module, Store, }, @@ -14,10 +15,18 @@ use std::{ use crate::error; use magnus::{ - class, function, gc::Marker, method, r_string::RString, scan_args, typed_data::Obj, - DataTypeFunctions, Error, Module as _, Object, RModule, Ruby, TryConvert, TypedData, Value, + block::Proc, + class, function, + gc::Marker, + method, + r_string::RString, + scan_args::scan_args, + typed_data::Obj, + value::{Opaque, ReprValue}, + DataTypeFunctions, Error, Module as _, Object, RArray, RModule, Ruby, TryConvert, TypedData, + Value, }; -use wasmtime::component::{Linker as LinkerImpl, LinkerInstance as LinkerInstanceImpl}; +use wasmtime::component::{Linker as LinkerImpl, LinkerInstance as LinkerInstanceImpl, Val}; use wasmtime_wasi::{ResourceTable, WasiCtx}; /// @yard @@ -72,7 +81,11 @@ impl Linker { let Ok(mut inner) = rb_self.inner.try_borrow_mut() else { return err!("Linker is not reentrant"); }; - let instance = ruby.obj_wrap(LinkerInstance::from_inner(inner.root())); + let instance = ruby.obj_wrap(LinkerInstance::from_inner( + inner.root(), + String::new(), // root path is empty + rb_self.as_value(), + )); let block_result: Result = ruby.yield_value(instance); instance.take_inner(); @@ -93,11 +106,14 @@ impl Linker { /// @return [Linker] +self+ pub fn instance(ruby: &Ruby, rb_self: Obj, name: RString) -> Result, Error> { let mut inner = rb_self.inner.borrow_mut(); - let instance = inner - .instance(unsafe { name.as_str() }?) - .map_err(|e| error!("{}", e))?; + let name_str = unsafe { name.as_str() }?; + let instance = inner.instance(name_str).map_err(|e| error!("{}", e))?; - let instance = ruby.obj_wrap(LinkerInstance::from_inner(instance)); + let instance = ruby.obj_wrap(LinkerInstance::from_inner( + instance, + name_str.to_string(), + rb_self.as_value(), + )); let block_result: Result = ruby.yield_value(instance); @@ -164,6 +180,8 @@ impl Linker { pub struct LinkerInstance<'a> { inner: RefCell>, refs: RefCell>, + path: String, // namespace path, e.g., "" for root, "math" for nested + parent_linker: Value, // Reference to parent Linker object for accessing func_types } unsafe impl Send for LinkerInstance<'_> {} @@ -171,6 +189,7 @@ unsafe impl Send for LinkerInstance<'_> {} impl DataTypeFunctions for LinkerInstance<'_> { fn mark(&self, marker: &Marker) { marker.mark_slice(self.refs.borrow().as_slice()); + marker.mark(self.parent_linker); } } @@ -193,10 +212,16 @@ impl<'a> MaybeInstanceImpl<'a> { } impl<'a> LinkerInstance<'a> { - fn from_inner(inner: LinkerInstanceImpl<'a, StoreData>) -> Self { + fn from_inner( + inner: LinkerInstanceImpl<'a, StoreData>, + path: String, + parent_linker: Value, + ) -> Self { Self { inner: RefCell::new(MaybeInstanceImpl::new(inner)), refs: RefCell::new(Vec::new()), + path, + parent_linker, } } @@ -230,12 +255,22 @@ impl<'a> LinkerInstance<'a> { return err!("LinkerInstance is not reentrant"); }; + let name_str = unsafe { name.as_str() }?; let inner = maybe_instance.get_mut()?; - let nested_inner = inner - .instance(unsafe { name.as_str()? }) - .map_err(|e| error!("{}", e))?; + let nested_inner = inner.instance(name_str).map_err(|e| error!("{}", e))?; + + // Build nested path: if parent is "", use name; otherwise "parent/name" + let nested_path = if rb_self.path.is_empty() { + name_str.to_string() + } else { + format!("{}/{}", rb_self.path, name_str) + }; - let nested_instance = ruby.obj_wrap(LinkerInstance::from_inner(nested_inner)); + let nested_instance = ruby.obj_wrap(LinkerInstance::from_inner( + nested_inner, + nested_path, + rb_self.parent_linker, + )); let block_result: Result = ruby.yield_value(nested_instance); nested_instance.take_inner(); @@ -245,6 +280,65 @@ impl<'a> LinkerInstance<'a> { } } + /// @yard + /// Define a host function in this linker instance. + /// + /// Host functions return plain Ruby values which are automatically validated + /// and converted based on the function's type signature from the component. + /// + /// @example Simple scalar return + /// root.func_new("add") do |a, b| + /// a + b # Returns u32 + /// end + /// + /// @example Returning a list + /// root.func_new("get-numbers") do + /// [1, 2, 3] # Returns list + /// end + /// + /// @example Returning a tuple + /// root.func_new("make-tuple") do |n, s, b| + /// [n, s, b] # Returns tuple + /// end + /// + /// @example Complex types (records, results, etc.) + /// root.func_new("make-point") do |x, y| + /// {"x" => x, "y" => y} # Returns record with x and y fields + /// end + /// + /// @def func_new(name, &block) + /// @param name [String] The function name + /// @yield [*args] The block implementing the host function + /// @yieldparam args [Array] The function arguments, converted from component values + /// @yieldreturn [Object] Result value matching the function's return type. Use arrays for lists and tuples, hashes for records. + /// @return [LinkerInstance] +self+ + fn func_new(_ruby: &Ruby, rb_self: Obj, args: &[Value]) -> Result, Error> { + let args = scan_args::<(RString,), (), (), (), (), Proc>(args)?; + let (name,) = args.required; + let callable = args.block; + + let name_str = unsafe { name.as_str() }?; + + // Get parent Linker - we'll store the callable there to prevent GC + // (rb_self is ephemeral and won't keep references alive after the block ends) + let parent_linker: Obj = Obj::try_convert(rb_self.parent_linker)?; + parent_linker.refs.borrow_mut().push(callable.as_value()); + + // Create the closure that will be called from Wasm + let func_closure = make_component_func_closure(callable.into()); + + let Ok(mut maybe_instance) = rb_self.inner.try_borrow_mut() else { + return err!("LinkerInstance is not reentrant"); + }; + + let inner = maybe_instance.get_mut()?; + inner + .func_new(name_str, func_closure) + .map_err(|e| error!("failed to define host function: {}", e))?; + + Ok(rb_self) + } + fn take_inner(&self) { let Ok(mut maybe_instance) = self.inner.try_borrow_mut() else { panic!("Linker instance is already borrowed, can't expire.") @@ -254,6 +348,81 @@ impl<'a> LinkerInstance<'a> { } } +/// Create a closure that wraps a Ruby Proc for use as a component host function +/// The closure uses the function's type signature for automatic validation and conversion +fn make_component_func_closure( + callable: Opaque, +) -> impl Fn( + wasmtime::StoreContextMut<'_, StoreData>, + wasmtime::component::types::ComponentFunc, + &[Val], + &mut [Val], +) -> wasmtime::Result<()> + + Send + + Sync + + 'static { + move |mut store_context: wasmtime::StoreContextMut<'_, StoreData>, + func: wasmtime::component::types::ComponentFunc, + params: &[Val], + results: &mut [Val]| { + let ruby = Ruby::get().unwrap(); + + // Convert Wasm params to Ruby values + let rparams = ruby.ary_new_capa(params.len()); + for (i, param) in params.iter().enumerate() { + let rb_value = + convert::component_val_to_rb(&ruby, param.clone(), None).map_err(|e| { + wasmtime::Error::msg(format!("failed to convert parameter at index {i}: {e}")) + })?; + rparams.push(rb_value).map_err(|e| { + wasmtime::Error::msg(format!("failed to push parameter at index {i}: {e}")) + })?; + } + + // Call the Ruby Proc + let callable = ruby.get_inner(callable); + let proc_result = callable.call::<_, Value>(rparams).map_err(|e| { + // Store the Ruby error on StoreData so it can be properly raised later + store_context.data_mut().set_error(e); + // Return a generic error that will be replaced with the Ruby error + wasmtime::Error::msg("") + })?; + + // Get expected result types from function signature + let results_types: Vec<_> = func.results().collect(); + let num_results = results_types.len(); + + // Handle result conversion based on arity + // Note: WIT only supports 0 or 1 return values (use tuples for multiple values) + match num_results { + 0 => { + // No return value expected + Ok(()) + } + 1 => { + // Single return value - convert directly + // Don't unwrap arrays - the value might be a list or tuple type + let expected_ty = &results_types[0]; + let converted = convert::rb_to_component_val(proc_result, None, expected_ty) + .map_err(|e| { + store_context.data_mut().set_error(e); + wasmtime::Error::msg("") + })?; + results[0] = converted; + Ok(()) + } + _ => { + // WIT doesn't support multiple return values - this should never happen + store_context.data_mut().set_error(Error::new( + ruby.exception_runtime_error(), + format!("unexpected number of results: {}", num_results), + )); + Err(wasmtime::Error::msg("")) + } + } + } +} + pub fn init(ruby: &Ruby, namespace: &RModule) -> Result<(), Error> { let linker = namespace.define_class("Linker", ruby.class_object())?; linker.define_singleton_method("new", function!(Linker::new, 1))?; @@ -264,6 +433,7 @@ pub fn init(ruby: &Ruby, namespace: &RModule) -> Result<(), Error> { let linker_instance = namespace.define_class("LinkerInstance", ruby.class_object())?; linker_instance.define_method("module", method!(LinkerInstance::module, 2))?; linker_instance.define_method("instance", method!(LinkerInstance::instance, 1))?; + linker_instance.define_method("func_new", method!(LinkerInstance::func_new, -1))?; Ok(()) } diff --git a/spec/fixtures/host-func-imports/.cargo/config.toml b/spec/fixtures/host-func-imports/.cargo/config.toml new file mode 100644 index 00000000..f4e8c002 --- /dev/null +++ b/spec/fixtures/host-func-imports/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-unknown-unknown" diff --git a/spec/fixtures/host-func-imports/.gitignore b/spec/fixtures/host-func-imports/.gitignore new file mode 100644 index 00000000..201d8caa --- /dev/null +++ b/spec/fixtures/host-func-imports/.gitignore @@ -0,0 +1,2 @@ +src/bindings.rs +Cargo.lock diff --git a/spec/fixtures/host-func-imports/Cargo.toml b/spec/fixtures/host-func-imports/Cargo.toml new file mode 100644 index 00000000..77b865b7 --- /dev/null +++ b/spec/fixtures/host-func-imports/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "host-func-imports" +version = "0.1.0" +edition = "2021" + +[dependencies] +wit-bindgen-rt = { version = "0.33.0", features = ["bitflags"] } + +[lib] +crate-type = ["cdylib"] + +[profile.release] +codegen-units = 1 +opt-level = "s" +debug = false +strip = true +lto = true + +[package.metadata.component] +package = "fixtures:host-func-imports" + +[package.metadata.component.dependencies] diff --git a/spec/fixtures/host-func-imports/README.md b/spec/fixtures/host-func-imports/README.md new file mode 100644 index 00000000..ee1c0507 --- /dev/null +++ b/spec/fixtures/host-func-imports/README.md @@ -0,0 +1,15 @@ +Wasm component fixture to test host function imports via `func_new`. + +This component imports various host functions with different type signatures +and provides guest functions that call them. + +Prerequisite: `cargo install cargo-component` + +To rebuild, run the following from the wasmtime-rb's root: +``` +( + cd spec/fixtures/host-func-imports && \ + cargo component build --release && \ + cp target/wasm32-unknown-unknown/release/host_func_imports.wasm ../ +) +``` diff --git a/spec/fixtures/host-func-imports/src/lib.rs b/spec/fixtures/host-func-imports/src/lib.rs new file mode 100644 index 00000000..393a395e --- /dev/null +++ b/spec/fixtures/host-func-imports/src/lib.rs @@ -0,0 +1,102 @@ +#[allow(warnings)] +mod bindings; + +use bindings::{math, Color, Guest, Permissions, Point, Shape}; + +struct Component; + +impl Guest for Component { + fn test_greet(name: String) -> String { + bindings::greet(&name) + } + + fn test_add(a: u32, b: u32) -> u32 { + bindings::add(a, b) + } + + fn test_constant() -> u32 { + bindings::get_constant() + } + + fn test_point(x: i32, y: i32) -> Point { + bindings::make_point(x, y) + } + + fn test_sum(numbers: Vec) -> i32 { + bindings::sum_list(&numbers) + } + + fn test_maybe(n: Option) -> Option { + bindings::maybe_double(n) + } + + fn test_divide(a: u32, b: u32) -> Result { + bindings::safe_divide(a, b) + } + + fn test_multiply(a: u32, b: u32) -> u32 { + math::multiply(a, b) + } + + fn test_get_numbers() -> Vec { + bindings::get_numbers() + } + + fn test_make_tuple(n: u32, s: String, b: bool) -> (u32, String, bool) { + bindings::make_tuple(n, &s, b) + } + + fn test_analyze_numbers(numbers: Vec) -> (i32, Vec) { + bindings::analyze_numbers(&numbers) + } + + fn test_s8(n: i8) -> i8 { + bindings::echo_s8(n) + } + + fn test_u8(n: u8) -> u8 { + bindings::echo_u8(n) + } + + fn test_s16(n: i16) -> i16 { + bindings::echo_s16(n) + } + + fn test_u16(n: u16) -> u16 { + bindings::echo_u16(n) + } + + fn test_s64(n: i64) -> i64 { + bindings::echo_s64(n) + } + + fn test_u64(n: u64) -> u64 { + bindings::echo_u64(n) + } + + fn test_f32(n: f32) -> f32 { + bindings::echo_f32(n) + } + + fn test_f64(n: f64) -> f64 { + bindings::echo_f64(n) + } + + fn test_char(c: char) -> char { + bindings::echo_char(c) + } + + fn test_enum(c: Color) -> Color { + bindings::echo_enum(c) + } + + fn test_variant(s: Shape) -> Shape { + bindings::echo_variant(s) + } + + fn test_flags(p: Permissions) -> Permissions { + bindings::echo_flags(p) + } +} + +bindings::export!(Component with_types_in bindings); diff --git a/spec/fixtures/host-func-imports/wit/world.wit b/spec/fixtures/host-func-imports/wit/world.wit new file mode 100644 index 00000000..1461589a --- /dev/null +++ b/spec/fixtures/host-func-imports/wit/world.wit @@ -0,0 +1,90 @@ +package fixtures:host-func-imports; + +world host-imports { + // Simple primitives + import greet: func(name: string) -> string; + import add: func(a: u32, b: u32) -> u32; + import get-constant: func() -> u32; + + // Complex types + record point { + x: s32, + y: s32, + } + + import make-point: func(x: s32, y: s32) -> point; + import sum-list: func(numbers: list) -> s32; + import maybe-double: func(n: option) -> option; + import safe-divide: func(a: u32, b: u32) -> result; + import get-numbers: func() -> list; + import make-tuple: func(n: u32, s: string, b: bool) -> tuple; + import analyze-numbers: func(numbers: list) -> tuple>; + + // Additional integer types + import echo-s8: func(n: s8) -> s8; + import echo-u8: func(n: u8) -> u8; + import echo-s16: func(n: s16) -> s16; + import echo-u16: func(n: u16) -> u16; + import echo-s64: func(n: s64) -> s64; + import echo-u64: func(n: u64) -> u64; + + // Float types + import echo-f32: func(n: f32) -> f32; + import echo-f64: func(n: f64) -> f64; + + // Char type + import echo-char: func(c: char) -> char; + + // Enum, variant, flags + enum color { + red, + green, + blue, + } + + variant shape { + circle(f32), + rectangle(tuple), + point, + } + + flags permissions { + read, + write, + execute, + } + + import echo-enum: func(c: color) -> color; + import echo-variant: func(s: shape) -> shape; + import echo-flags: func(p: permissions) -> permissions; + + // Nested instance + import math: interface { + multiply: func(a: u32, b: u32) -> u32; + } + + // Guest functions that call the imports + export test-greet: func(name: string) -> string; + export test-add: func(a: u32, b: u32) -> u32; + export test-constant: func() -> u32; + export test-point: func(x: s32, y: s32) -> point; + export test-sum: func(numbers: list) -> s32; + export test-maybe: func(n: option) -> option; + export test-divide: func(a: u32, b: u32) -> result; + export test-multiply: func(a: u32, b: u32) -> u32; + export test-get-numbers: func() -> list; + export test-make-tuple: func(n: u32, s: string, b: bool) -> tuple; + export test-analyze-numbers: func(numbers: list) -> tuple>; + export test-s8: func(n: s8) -> s8; + export test-u8: func(n: u8) -> u8; + export test-s16: func(n: s16) -> s16; + export test-u16: func(n: u16) -> u16; + export test-s64: func(n: s64) -> s64; + export test-u64: func(n: u64) -> u64; + export test-f32: func(n: f32) -> f32; + export test-f64: func(n: f64) -> f64; + export test-char: func(c: char) -> char; + export test-enum: func(c: color) -> color; + export test-variant: func(s: shape) -> shape; + export test-flags: func(p: permissions) -> permissions; +} diff --git a/spec/fixtures/host_func_imports.wasm b/spec/fixtures/host_func_imports.wasm new file mode 100644 index 00000000..8c70e7a8 Binary files /dev/null and b/spec/fixtures/host_func_imports.wasm differ diff --git a/spec/unit/component/linker_spec.rb b/spec/unit/component/linker_spec.rb index 01473c2b..fc2c1346 100644 --- a/spec/unit/component/linker_spec.rb +++ b/spec/unit/component/linker_spec.rb @@ -35,6 +35,568 @@ module Component .to be_instance_of(Wasmtime::Component::Instance) end end + + describe "LinkerInstance#func_new" do + it "defines a function" do + linker.root do |root| + root.func_new("greet") do |name| + "Hello, #{name}!" + end + end + + expect(linker).to be_a(Linker) + end + + it "defines functions in nested instances" do + linker.instance("math") do |math| + math.func_new("add") do |a, b| + a + b + end + end + + expect(linker).to be_a(Linker) + end + + it "requires a block" do + expect { + linker.root do |root| + root.func_new("no-block") + end + }.to raise_error(ArgumentError, /no block given/) + end + end + + describe "LinkerInstance#func_new integration" do + before(:all) do + @host_imports_component = Component.from_file( + GLOBAL_ENGINE, + "spec/fixtures/host_func_imports.wasm" + ) + end + + let(:t) { Type } + + # Helper to stub all required imports except the specified one(s) + # @param except [Symbol, Array, nil] Import(s) to skip stubbing (nil = stub all) + def stub_component_imports(linker, except: nil) + skip_funcs = Array(except).map(&:to_s).to_set + + # Stub root functions + linker.root do |root| + root.func_new("greet") { |name| name } unless skip_funcs.include?("greet") + root.func_new("add") { |a, b| a + b } unless skip_funcs.include?("add") + root.func_new("get-constant") { 0 } unless skip_funcs.include?("get-constant") + unless skip_funcs.include?("make-point") + root.func_new("make-point") do |x, y| + {"x" => x, "y" => y} + end + end + root.func_new("sum-list") { |nums| nums.sum } unless skip_funcs.include?("sum-list") + root.func_new("maybe-double") { |n| n } unless skip_funcs.include?("maybe-double") + unless skip_funcs.include?("safe-divide") + root.func_new("safe-divide") do |a, b| + Result.ok(a) + end + end + root.func_new("get-numbers") { [] } unless skip_funcs.include?("get-numbers") + unless skip_funcs.include?("make-tuple") + root.func_new("make-tuple") do |n, s, b| + [n, s, b] + end + end + unless skip_funcs.include?("analyze-numbers") + root.func_new("analyze-numbers") do |nums| + [0, nums] + end + end + # Additional integer types + root.func_new("echo-s8") { |n| n } unless skip_funcs.include?("echo-s8") + root.func_new("echo-u8") { |n| n } unless skip_funcs.include?("echo-u8") + root.func_new("echo-s16") { |n| n } unless skip_funcs.include?("echo-s16") + root.func_new("echo-u16") { |n| n } unless skip_funcs.include?("echo-u16") + root.func_new("echo-s64") { |n| n } unless skip_funcs.include?("echo-s64") + root.func_new("echo-u64") { |n| n } unless skip_funcs.include?("echo-u64") + # Float types + root.func_new("echo-f32") { |n| n } unless skip_funcs.include?("echo-f32") + root.func_new("echo-f64") { |n| n } unless skip_funcs.include?("echo-f64") + # Char type + root.func_new("echo-char") { |c| c } unless skip_funcs.include?("echo-char") + # Enum, variant, flags + root.func_new("echo-enum") { |c| c } unless skip_funcs.include?("echo-enum") + root.func_new("echo-variant") { |s| s } unless skip_funcs.include?("echo-variant") + root.func_new("echo-flags") { |p| p } unless skip_funcs.include?("echo-flags") + end + + # Stub math instance unless skipped + unless skip_funcs.include?("math") + linker.instance("math") do |math| + math.func_new("multiply") { |a, b| a * b } + end + end + end + + context "with primitive types" do + it "provides a string function" do + stub_component_imports(linker, except: :greet) + + linker.root do |root| + root.func_new("greet") do |name| + "Hello, #{name}!" + end + end + + instance = linker.instantiate(store, @host_imports_component) + result = instance.get_func("test-greet").call("World") + + expect(result).to eq("Hello, World!") + end + + it "provides a function with multiple params" do + stub_component_imports(linker, except: :add) + + linker.root do |root| + root.func_new("add") do |a, b| + a + b + end + end + + instance = linker.instantiate(store, @host_imports_component) + result = instance.get_func("test-add").call(42, 8) + + expect(result).to eq(50) + end + + it "provides a function with no params" do + stub_component_imports(linker, except: :"get-constant") + + linker.root do |root| + root.func_new("get-constant") do + 1234 + end + end + + instance = linker.instantiate(store, @host_imports_component) + result = instance.get_func("test-constant").call + + expect(result).to eq(1234) + end + end + + context "with complex types" do + it "provides a function returning a record" do + stub_component_imports(linker, except: :"make-point") + + linker.root do |root| + root.func_new("make-point") do |x, y| + {"x" => x, "y" => y} + end + end + + instance = linker.instantiate(store, @host_imports_component) + result = instance.get_func("test-point").call(10, 20) + + expect(result).to eq({"x" => 10, "y" => 20}) + end + + it "validates field types in records" do + stub_component_imports(linker, except: :"make-point") + + linker.root do |root| + root.func_new("make-point") do |_x, y| + # Try to use wrong type for x field (string instead of s32) + {"x" => "not a number", "y" => y} + end + end + + instance = linker.instantiate(store, @host_imports_component) + func = instance.get_func("test-point") + + expect { func.call(10, 20) }.to raise_error(TypeError, /no implicit conversion of String into Integer/) + end + + it "provides a function accepting a list" do + stub_component_imports(linker, except: :"sum-list") + + linker.root do |root| + root.func_new("sum-list") do |numbers| + numbers.sum + end + end + + instance = linker.instantiate(store, @host_imports_component) + result = instance.get_func("test-sum").call([1, 2, 3, 4, 5]) + + expect(result).to eq(15) + end + + it "provides a function with option type" do + stub_component_imports(linker, except: :"maybe-double") + + linker.root do |root| + root.func_new("maybe-double") do |n| + n.nil? ? nil : n * 2 + end + end + + instance = linker.instantiate(store, @host_imports_component) + + expect(instance.get_func("test-maybe").call(21)).to eq(42) + expect(instance.get_func("test-maybe").call(nil)).to be_nil + end + + it "provides a function with result type" do + stub_component_imports(linker, except: :"safe-divide") + + linker.root do |root| + root.func_new("safe-divide") do |a, b| + result_val = if b == 0 + Result.error("division by zero") + else + Result.ok(a / b) + end + result_val + end + end + + instance = linker.instantiate(store, @host_imports_component) + func = instance.get_func("test-divide") + + expect(func.call(10, 2)).to eq(Result.ok(5)) + expect(func.call(10, 0)).to eq(Result.error("division by zero")) + end + + it "provides a function returning a list" do + stub_component_imports(linker, except: :"get-numbers") + + linker.root do |root| + root.func_new("get-numbers") do + [1, 2, 3, 4, 5] + end + end + + instance = linker.instantiate(store, @host_imports_component) + result = instance.get_func("test-get-numbers").call + + expect(result).to eq([1, 2, 3, 4, 5]) + end + + it "provides a function returning a tuple" do + stub_component_imports(linker, except: :"make-tuple") + + linker.root do |root| + root.func_new("make-tuple") do |n, s, b| + [n, s, b] + end + end + + instance = linker.instantiate(store, @host_imports_component) + result = instance.get_func("test-make-tuple").call(42, "hello", true) + + expect(result).to eq([42, "hello", true]) + end + + it "provides a function returning a tuple containing a list" do + stub_component_imports(linker, except: :"analyze-numbers") + + linker.root do |root| + root.func_new("analyze-numbers") do |numbers| + [numbers.sum, numbers.sort] + end + end + + instance = linker.instantiate(store, @host_imports_component) + result = instance.get_func("test-analyze-numbers").call([5, 1, 3, 2, 4]) + + expect(result).to eq([15, [1, 2, 3, 4, 5]]) + end + + it "provides a function with s8" do + stub_component_imports(linker, except: :"echo-s8") + + linker.root do |root| + root.func_new("echo-s8") { |n| n } + end + + instance = linker.instantiate(store, @host_imports_component) + expect(instance.get_func("test-s8").call(-42)).to eq(-42) + expect(instance.get_func("test-s8").call(127)).to eq(127) + end + + it "provides a function with u8" do + stub_component_imports(linker, except: :"echo-u8") + + linker.root do |root| + root.func_new("echo-u8") { |n| n } + end + + instance = linker.instantiate(store, @host_imports_component) + expect(instance.get_func("test-u8").call(255)).to eq(255) + end + + it "provides a function with s16" do + stub_component_imports(linker, except: :"echo-s16") + + linker.root do |root| + root.func_new("echo-s16") { |n| n } + end + + instance = linker.instantiate(store, @host_imports_component) + expect(instance.get_func("test-s16").call(-1000)).to eq(-1000) + expect(instance.get_func("test-s16").call(32767)).to eq(32767) + end + + it "provides a function with u16" do + stub_component_imports(linker, except: :"echo-u16") + + linker.root do |root| + root.func_new("echo-u16") { |n| n } + end + + instance = linker.instantiate(store, @host_imports_component) + expect(instance.get_func("test-u16").call(65535)).to eq(65535) + end + + it "provides a function with s64" do + stub_component_imports(linker, except: :"echo-s64") + + linker.root do |root| + root.func_new("echo-s64") { |n| n } + end + + instance = linker.instantiate(store, @host_imports_component) + expect(instance.get_func("test-s64").call(-9_223_372_036_854_775_808)).to eq(-9_223_372_036_854_775_808) + expect(instance.get_func("test-s64").call(9_223_372_036_854_775_807)).to eq(9_223_372_036_854_775_807) + end + + it "provides a function with u64" do + stub_component_imports(linker, except: :"echo-u64") + + linker.root do |root| + root.func_new("echo-u64") { |n| n } + end + + instance = linker.instantiate(store, @host_imports_component) + expect(instance.get_func("test-u64").call(18_446_744_073_709_551_615)).to eq(18_446_744_073_709_551_615) + end + + it "provides a function with f32" do + stub_component_imports(linker, except: :"echo-f32") + + linker.root do |root| + root.func_new("echo-f32") { |n| n } + end + + instance = linker.instantiate(store, @host_imports_component) + result = instance.get_func("test-f32").call(3.14) + expect(result).to be_within(0.01).of(3.14) + end + + it "provides a function with f64" do + stub_component_imports(linker, except: :"echo-f64") + + linker.root do |root| + root.func_new("echo-f64") { |n| n } + end + + instance = linker.instantiate(store, @host_imports_component) + expect(instance.get_func("test-f64").call(3.141592653589793)).to eq(3.141592653589793) + end + + it "provides a function with char" do + stub_component_imports(linker, except: :"echo-char") + + linker.root do |root| + root.func_new("echo-char") { |c| c } + end + + instance = linker.instantiate(store, @host_imports_component) + expect(instance.get_func("test-char").call("A")).to eq("A") + expect(instance.get_func("test-char").call("🎉")).to eq("🎉") + end + + it "provides a function with enum" do + stub_component_imports(linker, except: :"echo-enum") + + linker.root do |root| + root.func_new("echo-enum") do |color| + color + end + end + + instance = linker.instantiate(store, @host_imports_component) + expect(instance.get_func("test-enum").call("red")).to eq("red") + expect(instance.get_func("test-enum").call("green")).to eq("green") + expect(instance.get_func("test-enum").call("blue")).to eq("blue") + end + + it "provides a function with variant" do + stub_component_imports(linker, except: :"echo-variant") + + linker.root do |root| + root.func_new("echo-variant") do |shape| + shape + end + end + + instance = linker.instantiate(store, @host_imports_component) + func = instance.get_func("test-variant") + + result = func.call(Variant.new("circle", 5.0)) + expect(result.name).to eq("circle") + expect(result.value).to be_within(0.01).of(5.0) + + result = func.call(Variant.new("rectangle", [10.0, 20.0])) + expect(result.name).to eq("rectangle") + expect(result.value).to eq([10.0, 20.0]) + + result = func.call(Variant.new("point", nil)) + expect(result.name).to eq("point") + expect(result.value).to be_nil + end + + it "provides a function with flags" do + stub_component_imports(linker, except: :"echo-flags") + + linker.root do |root| + root.func_new("echo-flags") do |perms| + perms + end + end + + instance = linker.instantiate(store, @host_imports_component) + func = instance.get_func("test-flags") + + expect(func.call([])).to eq([]) + expect(func.call(["read"])).to eq(["read"]) + expect(func.call(["read", "write"])).to eq(["read", "write"]) + expect(func.call(["read", "write", "execute"])).to eq(["read", "write", "execute"]) + end + end + + context "with nested instances" do + it "provides functions in nested instances" do + stub_component_imports(linker, except: :math) + + linker.instance("math") do |math| + math.func_new("multiply") do |a, b| + a * b + end + end + + instance = linker.instantiate(store, @host_imports_component) + result = instance.get_func("test-multiply").call(6, 7) + + expect(result).to eq(42) + end + end + + context "with stateful closures" do + it "maintains state across calls" do + counter = 0 + + stub_component_imports(linker, except: :"get-constant") + + linker.root do |root| + root.func_new("get-constant") do + counter += 1 + counter + end + end + + instance = linker.instantiate(store, @host_imports_component) + func = instance.get_func("test-constant") + + expect(func.call).to eq(1) + expect(func.call).to eq(2) + expect(func.call).to eq(3) + end + + it "allows accessing Ruby objects" do + log = [] + + stub_component_imports(linker, except: :greet) + + linker.root do |root| + root.func_new("greet") do |name| + log << name + "Hello, #{name}!" + end + end + + instance = linker.instantiate(store, @host_imports_component) + func = instance.get_func("test-greet") + + func.call("Alice") + func.call("Bob") + + expect(log).to eq(["Alice", "Bob"]) + end + end + + context "error handling" do + it "propagates Ruby exceptions" do + stub_component_imports(linker, except: :"get-constant") + + linker.root do |root| + root.func_new("get-constant") do + raise "Something went wrong" + end + end + + instance = linker.instantiate(store, @host_imports_component) + func = instance.get_func("test-constant") + + expect { func.call }.to raise_error(RuntimeError, /Something went wrong/) + end + + it "validates return values match declared types" do + stub_component_imports(linker, except: :add) + + linker.root do |root| + root.func_new("add") do |_a, _b| + "not a number" # Returns string when u32 expected + end + end + + instance = linker.instantiate(store, @host_imports_component) + func = instance.get_func("test-add") + + expect { func.call(1, 2) }.to raise_error(TypeError, /conversion of String into Integer/) + end + + it "validates field types in returned records" do + stub_component_imports(linker, except: :"make-point") + + linker.root do |root| + root.func_new("make-point") do |_x, y| + # Return record with wrong field type + {"x" => "not a number", "y" => y} + end + end + + instance = linker.instantiate(store, @host_imports_component) + func = instance.get_func("test-point") + + expect { func.call(10, 20) }.to raise_error(TypeError, /conversion of String into Integer/) + end + + it "validates list element types" do + stub_component_imports(linker, except: :"get-numbers") + + linker.root do |root| + root.func_new("get-numbers") do + # Return list with wrong element type (strings instead of s32) + ["not", "numbers"] + end + end + + instance = linker.instantiate(store, @host_imports_component) + func = instance.get_func("test-get-numbers") + + expect { func.call }.to raise_error(TypeError, /conversion of String into Integer/) + end + end + end end end end