From f870155197a6875a7e6656435b5e57c79cb8e1cb Mon Sep 17 00:00:00 2001 From: Dima Mikielewicz Date: Tue, 1 Sep 2026 14:48:20 +0200 Subject: [PATCH 1/4] fix(value): keep boolean table keys as booleans --- lib/lua/vm/value.ex | 10 ++++++++-- test/lua/vm/value_test.exs | 17 +++++++++++++++++ test/lua_test.exs | 7 +++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/lua/vm/value.ex b/lib/lua/vm/value.ex index cd55225d..d8ced682 100644 --- a/lib/lua/vm/value.ex +++ b/lib/lua/vm/value.ex @@ -268,7 +268,7 @@ defmodule Lua.VM.Value do def encode(map, state, fun_wrapper) when is_map(map) do {data, state} = Enum.reduce(map, {%{}, state}, fn {k, v}, {data, state} -> - key = if is_atom(k), do: Atom.to_string(k), else: k + key = table_key(k) {encoded_v, state} = encode(v, state, fun_wrapper) {Map.put(data, key, encoded_v), state} end) @@ -280,7 +280,7 @@ defmodule Lua.VM.Value do if keyword_list?(list) do {data, state} = Enum.reduce(list, {%{}, state}, fn {k, v}, {data, state} -> - key = Atom.to_string(k) + key = table_key(k) {encoded_v, state} = encode(v, state, fun_wrapper) {Map.put(data, key, encoded_v), state} end) @@ -324,6 +324,12 @@ defmodule Lua.VM.Value do {Enum.reverse(reversed), state} end + # Atom keys become Lua strings, except `true`/`false`: those are Lua + # booleans, and a boolean key must stay a boolean key. + defp table_key(key) when is_boolean(key), do: key + defp table_key(key) when is_atom(key), do: Atom.to_string(key) + defp table_key(key), do: key + defp keyword_list?([{k, _v} | rest]) when is_atom(k), do: keyword_list?(rest) defp keyword_list?([]), do: true defp keyword_list?(_), do: false diff --git a/test/lua/vm/value_test.exs b/test/lua/vm/value_test.exs index 35da1881..16649f95 100644 --- a/test/lua/vm/value_test.exs +++ b/test/lua/vm/value_test.exs @@ -80,6 +80,15 @@ defmodule Lua.VM.ValueTest do assert table.data["status"] == "married" end + test "keeps boolean keys as booleans" do + {{:tref, id}, state} = Value.encode(%{true => "yes", false => "no"}, new_state()) + + table = Map.fetch!(state.tables, id) + assert table.data[true] == "yes" + assert table.data[false] == "no" + refute Map.has_key?(table.data, "true") + end + test "encodes empty map" do {{:tref, id}, state} = Value.encode(%{}, new_state()) @@ -122,6 +131,14 @@ defmodule Lua.VM.ValueTest do assert table.data["age"] == 25 end + test "keeps boolean keys of a keyword-shaped list as booleans" do + {{:tref, id}, state} = Value.encode([{true, "yes"}, {false, "no"}], new_state()) + + table = Map.fetch!(state.tables, id) + assert table.data[true] == "yes" + assert table.data[false] == "no" + end + test "recursively encodes list elements" do {{:tref, id}, state} = Value.encode([%{x: 1}], new_state()) diff --git a/test/lua_test.exs b/test/lua_test.exs index 85abca68..0cfc483f 100644 --- a/test/lua_test.exs +++ b/test/lua_test.exs @@ -710,6 +710,13 @@ defmodule LuaTest do assert {[true], _lua} = Lua.eval!(lua, "return not value") end + test "boolean table keys survive a round trip" do + lua = Lua.set!(Lua.new(), [:flags], %{true => "yes", false => "no"}) + + assert {["yes", "no"], lua} = Lua.eval!(lua, "return flags[true], flags[false]") + assert Enum.sort(Lua.get!(lua, [:flags])) == [{false, "no"}, {true, "yes"}] + end + test "it raises for values that cannot be encoded" do error = "Lua runtime error: Failed to encode {:foo, :bar}" From 23dd1431a0a12ff4ae0b72db51edb9b2b7197806 Mon Sep 17 00:00:00 2001 From: Dima Mikielewicz Date: Tue, 1 Sep 2026 14:49:14 +0200 Subject: [PATCH 2/4] fix(value): treat the nan sentinel as a number --- lib/lua.ex | 4 ++++ lib/lua/util.ex | 1 + lib/lua/vm/stdlib/math.ex | 4 ++++ lib/lua/vm/value.ex | 3 +++ test/lua/util_test.exs | 1 + test/lua/vm/float_div_zero_test.exs | 14 ++++++++++++++ test/lua/vm/value_test.exs | 6 ++++++ 7 files changed, 33 insertions(+) diff --git a/lib/lua.ex b/lib/lua.ex index e0553662..b53385bd 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -999,6 +999,10 @@ defmodule Lua do {nil, lua} end + def encode!(%__MODULE__{} = lua, :nan) do + {:nan, lua} + end + def encode!(%__MODULE__{} = lua, value) when is_atom(value) and not is_boolean(value) do {Atom.to_string(value), lua} end diff --git a/lib/lua/util.ex b/lib/lua/util.ex index 6e01245e..d33bdb02 100644 --- a/lib/lua/util.ex +++ b/lib/lua/util.ex @@ -14,6 +14,7 @@ defmodule Lua.Util do def encoded?(true), do: true def encoded?(binary) when is_binary(binary), do: true def encoded?(number) when is_number(number), do: true + def encoded?(:nan), do: true def encoded?({:tref, _}), do: true def encoded?({:lua_closure, _, _}), do: true def encoded?({:compiled_closure, _, _}), do: true diff --git a/lib/lua/vm/stdlib/math.ex b/lib/lua/vm/stdlib/math.ex index f6054490..8a7d0aea 100644 --- a/lib/lua/vm/stdlib/math.ex +++ b/lib/lua/vm/stdlib/math.ex @@ -603,6 +603,10 @@ defmodule Lua.VM.Stdlib.Math do {["float"], state} end + defp math_type([:nan], state) do + {["float"], state} + end + defp math_type([_x], state) do {[nil], state} end diff --git a/lib/lua/vm/value.ex b/lib/lua/vm/value.ex index d8ced682..70eb0158 100644 --- a/lib/lua/vm/value.ex +++ b/lib/lua/vm/value.ex @@ -18,6 +18,7 @@ defmodule Lua.VM.Value do def type_name(v) when is_boolean(v), do: "boolean" def type_name(v) when is_integer(v), do: "number" def type_name(v) when is_float(v), do: "number" + def type_name(:nan), do: "number" def type_name(v) when is_binary(v), do: "string" def type_name({:tref, _}), do: "table" def type_name({:lua_closure, _, _}), do: "function" @@ -54,6 +55,7 @@ defmodule Lua.VM.Value do end end + def to_string(:nan), do: "nan" def to_string(v) when is_binary(v), do: v def to_string({:tref, id}), do: "table: 0x#{address(id)}" @@ -232,6 +234,7 @@ defmodule Lua.VM.Value do def encode(nil, state, _fun_wrapper), do: {nil, state} def encode(value, state, _fun_wrapper) when is_boolean(value), do: {value, state} def encode(value, state, _fun_wrapper) when is_number(value), do: {value, state} + def encode(:nan, state, _fun_wrapper), do: {:nan, state} def encode(value, state, _fun_wrapper) when is_binary(value), do: {value, state} def encode(value, state, _fun_wrapper) when is_atom(value), do: {Atom.to_string(value), state} diff --git a/test/lua/util_test.exs b/test/lua/util_test.exs index 303e1f7a..6c12badf 100644 --- a/test/lua/util_test.exs +++ b/test/lua/util_test.exs @@ -14,6 +14,7 @@ defmodule Lua.UtilTest do assert Lua.Util.encoded?(0) assert Lua.Util.encoded?(Enum.random(1..1000)) assert Lua.Util.encoded?(:rand.uniform()) + assert Lua.Util.encoded?(:nan) # tref {{:tref, _} = table, _lua} = Lua.encode!(Lua.new(), %{a: 1, b: 2}) diff --git a/test/lua/vm/float_div_zero_test.exs b/test/lua/vm/float_div_zero_test.exs index 39f756e8..f56a7965 100644 --- a/test/lua/vm/float_div_zero_test.exs +++ b/test/lua/vm/float_div_zero_test.exs @@ -68,6 +68,20 @@ defmodule Lua.VM.FloatDivZeroTest do test "NaN compared to a number is unequal" do assert run!("return 0/0 == 1") == [false] end + + test "NaN is a float number" do + assert run!("return type(0/0), math.type(0/0), tostring(0/0)") == ["number", "float", "nan"] + end + + test "NaN crosses the host boundary as :nan and back" do + {[:nan], lua} = Lua.eval!(Lua.new(), "return 0/0") + + assert {:nan, lua} = Lua.encode!(lua, :nan) + assert Lua.decode!(lua, :nan) == :nan + + lua = Lua.set!(lua, [:nan], :nan) + assert {[true, "number"], _lua} = Lua.eval!(lua, "return nan ~= nan, type(nan)") + end end describe "non-zero divisors still divide normally" do diff --git a/test/lua/vm/value_test.exs b/test/lua/vm/value_test.exs index 16649f95..067dc396 100644 --- a/test/lua/vm/value_test.exs +++ b/test/lua/vm/value_test.exs @@ -38,6 +38,12 @@ defmodule Lua.VM.ValueTest do assert {"hello", _state} = Value.encode(:hello, new_state()) assert {"Elixir.MyApp", _state} = Value.encode(MyApp, new_state()) end + + test "the nan sentinel passes through as a number" do + assert {:nan, _state} = Value.encode(:nan, new_state()) + assert Value.decode(:nan, new_state()) == :nan + assert Value.type_name(:nan) == "number" + end end describe "encode/2 functions" do From e391fcd24d40878c61dc80b484cc4e4f0bbf5bf3 Mon Sep 17 00:00:00 2001 From: Dima Mikielewicz Date: Tue, 1 Sep 2026 14:49:52 +0200 Subject: [PATCH 3/4] fix(api): raise Lua.RuntimeException when set! cannot encode a value --- lib/lua.ex | 13 +++++++------ test/lua_test.exs | 6 ++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/lua.ex b/lib/lua.ex index b53385bd..d77d9f44 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -352,7 +352,7 @@ defmodule Lua do if Util.encoded?(value) do {value, lua.state} else - Value.encode(value, lua.state, &wrap_callback(&1, function_name, scope)) + encode_value!(value, lua.state, &wrap_callback(&1, function_name, scope)) end state = do_set_nested(state, keys, encoded) @@ -1013,14 +1013,15 @@ defmodule Lua do if Util.encoded?(value) do {value, lua} else - {encoded, state} = Value.encode(value, state, &wrap_callback(&1, :anonymous, [])) + {encoded, state} = encode_value!(value, state, &wrap_callback(&1, :anonymous, [])) {encoded, %{lua | state: state}} end - rescue - _e in [ArgumentError] -> - reraise Lua.RuntimeException, "Failed to encode #{inspect(value)}", __STACKTRACE__ + end - _e in [FunctionClauseError] -> + defp encode_value!(value, state, fun_wrapper) do + Value.encode(value, state, fun_wrapper) + rescue + _e in [ArgumentError, FunctionClauseError] -> reraise Lua.RuntimeException, "Failed to encode #{inspect(value)}", __STACKTRACE__ end diff --git a/test/lua_test.exs b/test/lua_test.exs index 0cfc483f..2465fda4 100644 --- a/test/lua_test.exs +++ b/test/lua_test.exs @@ -725,6 +725,12 @@ defmodule LuaTest do end end + test "Lua.set!/3 raises the same error for values that cannot be encoded" do + assert_raise Lua.RuntimeException, ~r/Failed to encode #PID/, fn -> + Lua.set!(Lua.new(), [:pid], self()) + end + end + test "it raises for values that cannot be decoded" do error = "Lua runtime error: Failed to decode {}" From 45b38e0502142fe454bb02b09acc8e7818f3a1b5 Mon Sep 17 00:00:00 2001 From: Dima Mikielewicz Date: Tue, 1 Sep 2026 14:51:03 +0200 Subject: [PATCH 4/4] fix(vm): raise when # is applied to a value without a length --- lib/lua/vm/error_formatter.ex | 4 ++++ lib/lua/vm/executor.ex | 22 ++++++++++++------ test/lua/vm/length_operator_test.exs | 34 ++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 test/lua/vm/length_operator_test.exs diff --git a/lib/lua/vm/error_formatter.ex b/lib/lua/vm/error_formatter.ex index 8f0edf35..8c49d31a 100644 --- a/lib/lua/vm/error_formatter.ex +++ b/lib/lua/vm/error_formatter.ex @@ -282,6 +282,10 @@ defmodule Lua.VM.ErrorFormatter do "Relational operators (< <= > >=) only compare two numbers or two strings. Convert one operand so both sides share a type." end + defp build_suggestion(:type_error, :length_non_table, value_type) do + "The length operator (#) only works on strings and tables. Make sure the value is a string or table, not a #{format_type_name(value_type)}." + end + defp build_suggestion(:type_error, :length_not_integer, _value_type) do "The length operation returned a non-integer value. If you defined a __len metamethod, make sure it returns an integer." end diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index ccad3618..251b9f4d 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -467,13 +467,9 @@ defmodule Lua.VM.Executor do raise_index_type_error(value, nil, proto.source, name_hint, state) end - # The `_proto` parameter is unused today because `try_unary_metamethod` - # doesn't thread a `source` through; B5d-v2 will route `__len` errors - # back to `proto.source` (matching the other bridges' attribution), - # so the parameter stays in the signature for forward-compat. @doc false @spec dispatcher_length(term(), State.t(), term()) :: {term(), State.t()} - def dispatcher_length(value, state, _proto) do + def dispatcher_length(value, state, proto) do try_unary_metamethod("__len", value, state, fn -> case value do {:tref, id} -> @@ -487,11 +483,23 @@ defmodule Lua.VM.Executor do length(v) _ -> - 0 + raise_length_type_error(value, nil, proto.source, state) end end) end + # Lua 5.3 §3.4.7: `#` is defined for strings and tables only; any other + # operand without a `__len` metamethod is a type error, never 0. + defp raise_length_type_error(value, line, source, state) do + raise TypeError, + value: "attempt to get length of a #{Value.type_name(value)} value", + line: line, + source: source, + error_kind: :length_non_table, + value_type: value_type(value), + state: state + end + @doc false @spec dispatcher_coerce_numeric_for_controls(term(), term(), term(), State.t()) :: {number(), number(), number()} @@ -2597,7 +2605,7 @@ defmodule Lua.VM.Executor do length(v) _ -> - 0 + raise_length_type_error(value, line, proto.source, state) end end) diff --git a/test/lua/vm/length_operator_test.exs b/test/lua/vm/length_operator_test.exs new file mode 100644 index 00000000..0478c475 --- /dev/null +++ b/test/lua/vm/length_operator_test.exs @@ -0,0 +1,34 @@ +defmodule Lua.VM.LengthOperatorTest do + @moduledoc """ + Pins Lua 5.3 §3.4.7: `#` applies to strings and tables (or any value with a + `__len` metamethod); every other operand is a type error, never 0. + """ + + use ExUnit.Case, async: true + + defp run!(code) do + {results, _lua} = Lua.eval!(Lua.new(), code) + results + end + + test "strings and tables have a length" do + assert run!(~S(return #"abc", #{1, 2, 3}, #{})) == [3, 3, 0] + end + + test "__len is honoured" do + assert run!("return #setmetatable({}, {__len = function() return 7 end})") == [7] + end + + for {literal, type} <- [{"nil", "nil"}, {"true", "boolean"}, {"5", "number"}, {"print", "function"}] do + test "# on a #{type} value raises" do + assert_raise Lua.RuntimeException, ~r/attempt to get length of a #{unquote(type)} value/, fn -> + Lua.eval!(Lua.new(), "return #(#{unquote(literal)})") + end + end + end + + test "the error is catchable with pcall" do + assert [false, message] = run!("return pcall(function() return #nil end)") + assert message =~ "attempt to get length of a nil value" + end +end