diff --git a/CHANGELOG.md b/CHANGELOG.md index e7178f1b..340a1534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,46 +21,47 @@ Wasmtime rewrote their fuel-related API and simplified it. To remain consistent The underlying implementation of the fuel system got rewritten as well. If you are using fuel in your app, please check your fuel consumption values. -* Thanks to @RoyalIcing for helping us keeping our dependencies up to date for this release 💜 +- Thanks to @RoyalIcing for helping us keeping our dependencies up to date for this release 💜 ### Added -* official support for Elixir 1.15 and 1.16 -* fuel-related API got rewritten, because the underlying Wasm library (wasmtime) changed their API and we want to be consistent. Added `Store.get_fuel/1` and `Store.set_fuel/2` which is a much simpler API than before. +- official support for Elixir 1.15 and 1.16 +- fuel-related API got rewritten, because the underlying Wasm library (wasmtime) changed their API and we want to be consistent. Added `Store.get_fuel/1` and `Store.set_fuel/2` which is a much simpler API than before. +- read and write a global’s value with `Instance.get_global_value/3` and `Instance.set_global_value/4` ([#540](https://github.com/tessi/wasmex/pull/540)) ### Removed -* removed support for Elixir 1.12 -* with the fuel-related API changed, the existing methods on `Store` (`consume_fuel`, `fuel_remaining`, `add_fuel`) were removed. Please call `set_fuel/2` and `get_fuel/1` instead. +- removed support for Elixir 1.12 +- with the fuel-related API changed, the existing methods on `Store` (`consume_fuel`, `fuel_remaining`, `add_fuel`) were removed. Please call `set_fuel/2` and `get_fuel/1` instead. ### Changed -* Dependency updates (most notably wasmtime and rustler) -* removed dialyzer +- Dependency updates (most notably wasmtime and rustler) +- removed dialyzer ## [0.8.4] - 2023-06-?? ### Added -* added support for multi-value returns from WASM and elixir callbacks. This enables passing string return values safely by pointer and length, for example. +- added support for multi-value returns from WASM and elixir callbacks. This enables passing string return values safely by pointer and length, for example. ## [0.8.3] - 2023-05-24 ### Added -* added support for `riscv64gc-unknown-linux-gnu` -* added support for OTP 26 +- added support for `riscv64gc-unknown-linux-gnu` +- added support for OTP 26 ### Changed -* updated rustler from 0.27.0 to 0.28.0 -* updated wasmtime from 4.0.1 to 9.0.1 +- updated rustler from 0.27.0 to 0.28.0 +- updated wasmtime from 4.0.1 to 9.0.1 ## [0.8.2] - 2023-01-08 ## Added -* list `aarch64-unknown-linux-musl` in rustler targets, so we actually include it in our releases +- list `aarch64-unknown-linux-musl` in rustler targets, so we actually include it in our releases ## [0.8.1] - 2023-01-08 @@ -75,22 +76,21 @@ Today, a `Wasmex.Engine` already gives us a faster way to precompile modules wit ### Added -* Added precompiled binary for `aarch64-unknown-linux-musl` -* Added support for setting store limits. This allows users to limit memory usage, instance creation, table sizes and more. See `Wasmex.StoreLimits` for details. -* Added support for metering/fuel_consumption. This allows users to limit CPU usage. A `Wasmex.Store` can be given fuel, where each Wasm instruction of a running Wasm binary uses a certain amount of fuel. If no fuel remains, execution stops. See `Wasmex.EngineConfig` for details. -* Added `Wasmex.EngineConfig` as a place for more complex Wasm settings. With this release an engine can be configured to provide more detailed backtraces on errors during Wasm execution by setting the `wasm_backtrace_details` flag. -* Added `Wasmex.Engine.precompile_module/2` which allows module precompilation from a .wat or .wasm binary without the need to instantiate said module. A precompiled module can be hydrated with `Module.unsafe_deserialize/2`. -* Added `Wasmex.module/1` and `Wasmex.store/1` to access the module and store of a running Wasmex GenServer process. -* Added option to `Wasmex.EngineConfig` to configure the `cranelift_opt_level` (:none, :speed, :speed_and_size) allowing users to trade compilation time against execution speed +- Added precompiled binary for `aarch64-unknown-linux-musl` +- Added support for setting store limits. This allows users to limit memory usage, instance creation, table sizes and more. See `Wasmex.StoreLimits` for details. +- Added support for metering/fuel_consumption. This allows users to limit CPU usage. A `Wasmex.Store` can be given fuel, where each Wasm instruction of a running Wasm binary uses a certain amount of fuel. If no fuel remains, execution stops. See `Wasmex.EngineConfig` for details. +- Added `Wasmex.EngineConfig` as a place for more complex Wasm settings. With this release an engine can be configured to provide more detailed backtraces on errors during Wasm execution by setting the `wasm_backtrace_details` flag. +- Added `Wasmex.Engine.precompile_module/2` which allows module precompilation from a .wat or .wasm binary without the need to instantiate said module. A precompiled module can be hydrated with `Module.unsafe_deserialize/2`. +- Added `Wasmex.module/1` and `Wasmex.store/1` to access the module and store of a running Wasmex GenServer process. +- Added option to `Wasmex.EngineConfig` to configure the `cranelift_opt_level` (:none, :speed, :speed_and_size) allowing users to trade compilation time against execution speed ### Changed -* `mix.exs` now also requires at least Elixir 1.12 -* `Module.unsafe_deserialize/2` now accepts a `Wasmex.Engine` in addition to the serialized module binary. It's best to hydrate a module using the same engine config used to serialize or precompile it. It has no harsh consequences today, but will be important when we add more Wasm features (e.g. SIMD support) in the future. -* added typespecs for all public `Wasmex` methods -* improved documentation and typespecs -* allow starting the `Wasmex` GenServer with a `%{bytes: bytes, store: store}` map as a convenience to spare users the task of manually compiling a `Wasmex.Module` - +- `mix.exs` now also requires at least Elixir 1.12 +- `Module.unsafe_deserialize/2` now accepts a `Wasmex.Engine` in addition to the serialized module binary. It's best to hydrate a module using the same engine config used to serialize or precompile it. It has no harsh consequences today, but will be important when we add more Wasm features (e.g. SIMD support) in the future. +- added typespecs for all public `Wasmex` methods +- improved documentation and typespecs +- allow starting the `Wasmex` GenServer with a `%{bytes: bytes, store: store}` map as a convenience to spare users the task of manually compiling a `Wasmex.Module` ## [0.8.0] - 2023-01-03 @@ -108,30 +108,30 @@ Please visit the list of changes below for more details. ### Added -* Added support for OTP 25 -* Added support for Elixir 1.14 +- Added support for OTP 25 +- Added support for Elixir 1.14 ### Removed -* Removed official support for OTP 22 and 23 -* Removed official support for Elixir 1.12 -* Removed `Wasmex.Module.set_name()` without replacement as this is not supported by Wasmtime -* Removed `Wasmex.Memory.bytes_per_element()` without replacement because we dropped support for different data types and now only handle bytes -* Removed `Wasmex.Pipe.set_len()` without replacement -* WASI directory/file preopens can not configure read/write/create permissions anymore because wasmtime does not support this feature well. We very much plan to add support back [once wasmtime allows](https://github.com/bytecodealliance/wasmtime/issues/4273). +- Removed official support for OTP 22 and 23 +- Removed official support for Elixir 1.12 +- Removed `Wasmex.Module.set_name()` without replacement as this is not supported by Wasmtime +- Removed `Wasmex.Memory.bytes_per_element()` without replacement because we dropped support for different data types and now only handle bytes +- Removed `Wasmex.Pipe.set_len()` without replacement +- WASI directory/file preopens can not configure read/write/create permissions anymore because wasmtime does not support this feature well. We very much plan to add support back [once wasmtime allows](https://github.com/bytecodealliance/wasmtime/issues/4273). ### Changed -* Changed the underlying Wasm engine from wasmer to [wasmtime](https://wasmtime.dev) -* Removed `Wasmex.Instance.new()` and `Wasmex.Instance.new_wasi()` in favor of `Wasmex.Store.new()` and `Wasmex.Store.new_wasi()`. -* WASI-options to `Wasmex.Store.new_wasi()` are now a proper struct `Wasmex.Wasi.WasiOptions` to improve typespecs, docs, and compile-time warnings. -* `Wasmex.Pipe` went through an internal rewrite. It is now a positioned read/write stream. You may change the read/write position with `Wasmex.Pipe.seek()` -* Renamed `Wasmex.Pipe.create()` to `Wasmex.Pipe.new()` to be consistent with other struct-creation calls -* Renamed `Wasmex.Memory.length()` to `Wasmex.Memory.size()` for consistenct with other `size` methods -* Renamed `Wasmex.Memory.set()` to `Wasmex.Memory.set_byte()` -* Renamed `Wasmex.Memory.get()` to `Wasmex.Memory.get_byte()` -* Updated and rewrote most of the docs - all examples are now doctests and tested on CI -* Updated all Elixir/Rust dependencies +- Changed the underlying Wasm engine from wasmer to [wasmtime](https://wasmtime.dev) +- Removed `Wasmex.Instance.new()` and `Wasmex.Instance.new_wasi()` in favor of `Wasmex.Store.new()` and `Wasmex.Store.new_wasi()`. +- WASI-options to `Wasmex.Store.new_wasi()` are now a proper struct `Wasmex.Wasi.WasiOptions` to improve typespecs, docs, and compile-time warnings. +- `Wasmex.Pipe` went through an internal rewrite. It is now a positioned read/write stream. You may change the read/write position with `Wasmex.Pipe.seek()` +- Renamed `Wasmex.Pipe.create()` to `Wasmex.Pipe.new()` to be consistent with other struct-creation calls +- Renamed `Wasmex.Memory.length()` to `Wasmex.Memory.size()` for consistenct with other `size` methods +- Renamed `Wasmex.Memory.set()` to `Wasmex.Memory.set_byte()` +- Renamed `Wasmex.Memory.get()` to `Wasmex.Memory.get_byte()` +- Updated and rewrote most of the docs - all examples are now doctests and tested on CI +- Updated all Elixir/Rust dependencies ## [0.7.1] - 2022-05-25 @@ -139,7 +139,6 @@ Please visit the list of changes below for more details. - Added an optional fourth parameter to `call_function`, `timeout`, which accepts a value in milliseconds that will cap the execution time of the function. The default behavior if not supplied is preserved, which is a 5 second timeout. Thanks @brooksmtownsend for this contribution - ## [0.7.0] - 2022-03-27 ### Added diff --git a/lib/wasmex/instance.ex b/lib/wasmex/instance.ex index f07f8bb1..bf90b12e 100644 --- a/lib/wasmex/instance.ex +++ b/lib/wasmex/instance.ex @@ -172,6 +172,75 @@ defmodule Wasmex.Instance do def memory(store, instance) do Wasmex.Memory.from_instance(store, instance) end + + @doc ~S""" + Reads the value of an exported global. + + ## Examples + + iex> wat = "(module + ...> (global $answer i32 (i32.const 42)) + ...> (export \"answer\" (global $answer)) + ...> )" + iex> {:ok, store} = Wasmex.Store.new() + iex> {:ok, module} = Wasmex.Module.compile(store, wat) + iex> {:ok, instance} = Wasmex.Instance.new(store, module, %{}) + iex> Wasmex.Instance.get_global_value(store, instance, "answer") + {:ok, 42} + iex> Wasmex.Instance.get_global_value(store, instance, "not_a_global") + {:error, "exported global `not_a_global` not found"} + """ + @spec get_global_value(Wasmex.StoreOrCaller.t(), __MODULE__.t(), binary()) :: + {:ok, number()} | {:error, binary()} + def get_global_value(store_or_caller, instance, global_name) do + %{resource: store_or_caller_resource} = store_or_caller + %__MODULE__{resource: instance_resource} = instance + + Wasmex.Native.instance_get_global_value( + store_or_caller_resource, + instance_resource, + global_name + ) + |> case do + {:error, _reason} = term -> term + result when is_number(result) -> {:ok, result} + end + end + + @doc ~S""" + Sets the value of an exported mutable global. + + ## Examples + + iex> wat = "(module + ...> (global $count (mut i32) (i32.const 0)) + ...> (export \"count\" (global $count)) + ...> )" + iex> {:ok, store} = Wasmex.Store.new() + iex> {:ok, module} = Wasmex.Module.compile(store, wat) + iex> {:ok, instance} = Wasmex.Instance.new(store, module, %{}) + iex> Wasmex.Instance.set_global_value(store, instance, "count", 1) + :ok + iex> Wasmex.Instance.get_global_value(store, instance, "count") + {:ok, 1} + """ + @spec set_global_value(Wasmex.StoreOrCaller.t(), __MODULE__.t(), binary(), number()) :: + {:ok, number()} | {:error, binary()} + def set_global_value(store_or_caller, instance, global_name, new_value) do + %{resource: store_or_caller_resource} = store_or_caller + %__MODULE__{resource: instance_resource} = instance + + Wasmex.Native.instance_set_global_value( + store_or_caller_resource, + instance_resource, + global_name, + new_value + ) + |> case do + {} -> :ok + {:error, _reason} = term -> term + end + end end defimpl Inspect, for: Wasmex.Instance do diff --git a/lib/wasmex/native.ex b/lib/wasmex/native.ex index eb7eed22..6426be89 100644 --- a/lib/wasmex/native.ex +++ b/lib/wasmex/native.ex @@ -52,6 +52,17 @@ defmodule Wasmex.Native do ), do: error() + def instance_get_global_value(_store_or_caller_resource, _instance_resource, _global_name), + do: error() + + def instance_set_global_value( + _store_or_caller_resource, + _instance_resource, + _global_name, + _new_value + ), + do: error() + def memory_from_instance(_store_resource, _memory_resource), do: error() def memory_size(_store_resource, _memory_resource), do: error() def memory_grow(_store_resource, _memory_resource, _pages), do: error() diff --git a/native/wasmex/src/instance.rs b/native/wasmex/src/instance.rs index 14d1e173..ba12f341 100644 --- a/native/wasmex/src/instance.rs +++ b/native/wasmex/src/instance.rs @@ -74,6 +74,100 @@ fn link_and_create_instance( .map_err(|err| Error::Term(Box::new(err.to_string()))) } +#[rustler::nif(name = "instance_get_global_value", schedule = "DirtyCpu")] +pub fn get_global_value( + env: rustler::Env, + store_or_caller_resource: ResourceArc, + instance_resource: ResourceArc, + global_name: String, +) -> NifResult { + let instance: Instance = *(instance_resource.inner.lock().map_err(|e| { + rustler::Error::Term(Box::new(format!( + "Could not unlock instance resource as the mutex was poisoned: {e}" + ))) + })?); + let mut store_or_caller: &mut StoreOrCaller = + &mut *(store_or_caller_resource.inner.lock().map_err(|e| { + rustler::Error::Term(Box::new(format!( + "Could not unlock instance/store resource as the mutex was poisoned: {e}" + ))) + })?); + + let global = instance + .get_global(&mut store_or_caller, &global_name) + .ok_or_else(|| { + rustler::Error::Term(Box::new(format!( + "exported global `{global_name}` not found" + ))) + })?; + + let value = global.get(&mut store_or_caller); + + match value { + Val::I32(i) => Ok(i.encode(env)), + Val::I64(i) => Ok(i.encode(env)), + Val::F32(i) => Ok(f32::from_bits(i).encode(env)), + Val::F64(i) => Ok(f64::from_bits(i).encode(env)), + // encoding V128 is not yet supported by rustler + Val::V128(_) => Err(rustler::Error::Term(Box::new("unable_to_return_v128_type"))), + Val::FuncRef(_) => Err(rustler::Error::Term(Box::new( + "unable_to_return_func_ref_type", + ))), + Val::ExternRef(_) => Err(rustler::Error::Term(Box::new( + "unable_to_return_extern_ref_type", + ))), + } +} + +#[rustler::nif(name = "instance_set_global_value", schedule = "DirtyCpu")] +pub fn set_global_value( + store_or_caller_resource: ResourceArc, + instance_resource: ResourceArc, + global_name: String, + new_value: Term, +) -> NifResult<()> { + let instance: Instance = *(instance_resource.inner.lock().map_err(|e| { + rustler::Error::Term(Box::new(format!( + "Could not unlock instance resource as the mutex was poisoned: {e}" + ))) + })?); + let mut store_or_caller: &mut StoreOrCaller = + &mut *(store_or_caller_resource.inner.lock().map_err(|e| { + rustler::Error::Term(Box::new(format!( + "Could not unlock instance/store resource as the mutex was poisoned: {e}" + ))) + })?); + + let global = instance + .get_global(&mut store_or_caller, &global_name) + .ok_or_else(|| { + rustler::Error::Term(Box::new(format!( + "exported global `{global_name}` not found" + ))) + })?; + + let global_type = global.ty(&store_or_caller).content().clone(); + + let new_value = decode_term_as_wasm_value(global_type.clone(), new_value).ok_or_else(|| { + rustler::Error::Term(Box::new(format!( + "Cannot convert to a WebAssembly {:?} value. Given `{:?}`.", + global_type, + PrintableTermType::PrintTerm(new_value.get_type()) + ))) + })?; + + let val: Val = match new_value { + WasmValue::I32(value) => value.into(), + WasmValue::I64(value) => value.into(), + WasmValue::F32(value) => value.into(), + WasmValue::F64(value) => value.into(), + }; + + global + .set(&mut store_or_caller, val) + .map_err(|e| rustler::Error::Term(Box::new(format!("Could not set global: {e}")))) +} + #[rustler::nif(name = "instance_function_export_exists")] pub fn function_export_exists( store_or_caller_resource: ResourceArc, @@ -225,6 +319,36 @@ pub enum WasmValue { F64(f64), } +fn decode_term_as_wasm_value(expected_type: ValType, term: Term) -> Option { + let value = match (expected_type, term.get_type()) { + (ValType::I32, TermType::Integer | TermType::Float) => match term.decode::() { + Ok(value) => WasmValue::I32(value), + Err(_) => return None, + }, + (ValType::I64, TermType::Integer | TermType::Float) => match term.decode::() { + Ok(value) => WasmValue::I64(value), + Err(_) => return None, + }, + (ValType::F32, TermType::Integer | TermType::Float) => match term.decode::() { + Ok(value) => { + if value.is_finite() { + WasmValue::F32(value) + } else { + return None; + } + } + Err(_) => return None, + }, + (ValType::F64, TermType::Integer | TermType::Float) => match term.decode::() { + Ok(value) => WasmValue::F64(value), + Err(_) => return None, + }, + (_val_type, _term_type) => return None, + }; + + Some(value) +} + pub fn decode_function_param_terms( params: &[ValType], function_param_terms: Vec, @@ -243,65 +367,23 @@ pub fn decode_function_param_terms( .zip(function_param_terms.into_iter()) .enumerate() { - let value = match (param, given_param.get_type()) { - (ValType::I32, TermType::Integer | TermType::Float) => { - match given_param.decode::() { - Ok(value) => WasmValue::I32(value), - Err(_) => { - return Err(format!( - "Cannot convert argument #{} to a WebAssembly i32 value.", - nth + 1 - )); - } - } - } - (ValType::I64, TermType::Integer | TermType::Float) => { - match given_param.decode::() { - Ok(value) => WasmValue::I64(value), - Err(_) => { - return Err(format!( - "Cannot convert argument #{} to a WebAssembly i64 value.", - nth + 1 - )); - } - } - } - (ValType::F32, TermType::Integer | TermType::Float) => { - match given_param.decode::() { - Ok(value) => { - if value.is_finite() { - WasmValue::F32(value) - } else { - return Err(format!( - "Cannot convert argument #{} to a WebAssembly f32 value.", - nth + 1 - )); - } - } - Err(_) => { - return Err(format!( - "Cannot convert argument #{} to a WebAssembly f32 value.", - nth + 1 - )); - } - } - } - (ValType::F64, TermType::Integer | TermType::Float) => { - match given_param.decode::() { - Ok(value) => WasmValue::F64(value), - Err(_) => { - return Err(format!( - "Cannot convert argument #{} to a WebAssembly f64 value.", - nth + 1 - )); - } - } + let value = match ( + decode_term_as_wasm_value(param.clone(), given_param), + given_param.get_type(), + ) { + (Some(value), _) => value, + (_, TermType::Integer | TermType::Float) => { + return Err(format!( + "Cannot convert argument #{} to a WebAssembly {} value.", + nth + 1, + format!("{:?}", param).to_lowercase() + )) } - (val_type, term_type) => { + (_, term_type) => { return Err(format!( "Cannot convert argument #{} to a WebAssembly {:?} value. Given `{:?}`.", nth + 1, - val_type, + param, PrintableTermType::PrintTerm(term_type) )); } diff --git a/native/wasmex/src/lib.rs b/native/wasmex/src/lib.rs index a8cc719b..d9a5da1a 100644 --- a/native/wasmex/src/lib.rs +++ b/native/wasmex/src/lib.rs @@ -20,6 +20,8 @@ rustler::init! { [ engine::new, engine::precompile_module, + instance::get_global_value, + instance::set_global_value, instance::call_exported_function, instance::function_export_exists, instance::new, diff --git a/test/example_wasm_files/globals.wat b/test/example_wasm_files/globals.wat new file mode 100644 index 00000000..efefaade --- /dev/null +++ b/test/example_wasm_files/globals.wat @@ -0,0 +1,9 @@ +(module + (global $meaning_of_life (export "meaning_of_life") i32 (i32.const 42)) + (global (export "count_32") (mut i32) (i32.const -32)) + (global (export "count_64") (mut i64) (i64.const -64)) + (global (export "externref") externref (ref.null extern)) + (global (export "funcref") funcref (ref.null func)) + (global (export "bad_pi_32") (mut f32) (f32.const 0)) + (global (export "bad_pi_64") (mut f64) (f64.const 0)) +) \ No newline at end of file diff --git a/test/wasmex/instance_test.exs b/test/wasmex/instance_test.exs index aa4489ad..e74d5e1f 100644 --- a/test/wasmex/instance_test.exs +++ b/test/wasmex/instance_test.exs @@ -195,4 +195,64 @@ defmodule Wasmex.InstanceTest do {:ok, %Wasmex.Memory{resource: _}} = Wasmex.Instance.memory(store, instance) end end + + describe "globals" do + setup do + wat = File.read!("#{Path.dirname(__ENV__.file)}/../example_wasm_files/globals.wat") + {:ok, store} = Wasmex.Store.new() + {:ok, module} = Wasmex.Module.compile(store, wat) + {:ok, instance} = Wasmex.Instance.new(store, module, %{}) + %{instance: instance, store: store} + end + + test t(&Wasmex.Instance.get_global_value/3), context do + store = context[:store] + instance = context[:instance] + + assert {:error, "exported global `unknown_global` not found"} = + Wasmex.Instance.get_global_value(store, instance, "unknown_global") + + assert {:ok, 42} = Wasmex.Instance.get_global_value(store, instance, "meaning_of_life") + assert {:ok, -32} = Wasmex.Instance.get_global_value(store, instance, "count_32") + assert {:ok, -64} = Wasmex.Instance.get_global_value(store, instance, "count_64") + + assert {:error, "unable_to_return_extern_ref_type"} = + Wasmex.Instance.get_global_value(store, instance, "externref") + + assert {:error, "unable_to_return_func_ref_type"} = + Wasmex.Instance.get_global_value(store, instance, "funcref") + end + + test t(&Wasmex.Instance.set_global_value/4), context do + store = context[:store] + instance = context[:instance] + + assert {:error, "exported global `unknown_global` not found"} = + Wasmex.Instance.set_global_value(store, instance, "unknown_global", 0) + + assert {:error, "Could not set global: immutable global cannot be set"} = + Wasmex.Instance.set_global_value(store, instance, "meaning_of_life", 0) + + assert {:error, "Cannot convert to a WebAssembly I32 value. Given `Atom`."} = + Wasmex.Instance.set_global_value(store, instance, "count_32", :abc) + + assert :ok = Wasmex.Instance.set_global_value(store, instance, "count_32", 99) + assert {:ok, 99} = Wasmex.Instance.get_global_value(store, instance, "count_32") + + assert :ok = Wasmex.Instance.set_global_value(store, instance, "count_64", 17) + assert {:ok, 17} = Wasmex.Instance.get_global_value(store, instance, "count_64") + + assert :ok = Wasmex.Instance.set_global_value(store, instance, "bad_pi_32", 3.14) + + assert_in_delta 3.14, + elem(Wasmex.Instance.get_global_value(store, instance, "bad_pi_32"), 1), + 0.01 + + assert :ok = Wasmex.Instance.set_global_value(store, instance, "bad_pi_64", 3.14) + + assert_in_delta 3.14, + elem(Wasmex.Instance.get_global_value(store, instance, "bad_pi_64"), 1), + 0.01 + end + end end