diff --git a/lib/lua/compiler/bytecode.ex b/lib/lua/compiler/bytecode.ex index bbdcac53..a9c700f8 100644 --- a/lib/lua/compiler/bytecode.ex +++ b/lib/lua/compiler/bytecode.ex @@ -68,11 +68,11 @@ defmodule Lua.Compiler.Bytecode do @op_numeric_for 36 # B5c-v2: closures, upvalues, varargs, multi-return, loops, self, concat. - # After this block lands, the only opcodes the encoder still rejects are - # the data-shape concerns explicitly out of scope (`:goto` / `:label` for - # label-resolution semantics, `:set_global` for codegen vestige, and the - # bitwise family — none of which are blockers for the closures / OOP / - # string_ops benchmarks). + # The bitwise family and the `:set_list` multi-return tail (below) since + # joined the covered set, so the only opcodes the encoder still rejects + # are `:goto` / `:label` (label-resolution semantics not yet ported to the + # dispatcher's flat tuple model) and `:set_global` (a codegen vestige; + # modern codegen emits `:set_field` on `_ENV` instead). @op_closure 37 @op_set_upvalue 38 @op_get_open_upvalue 39 @@ -93,6 +93,18 @@ defmodule Lua.Compiler.Bytecode do @op_generic_for 51 @op_close_upvalues 52 + # Bitwise family (band/bor/bxor/shl/shr/bnot) plus the `:set_list` + # multi-return tail. Each compiles a single op that previously deopted + # the whole enclosing prototype to the interpreter. Tags stay contiguous + # to keep the dispatcher's jump table dense. + @op_bitwise_and 53 + @op_bitwise_or 54 + @op_bitwise_xor 55 + @op_shift_left 56 + @op_shift_right 57 + @op_bitwise_not 58 + @op_set_list_multi 59 + @doc """ Compile a prototype, populating its `bytecode` field on success. @@ -181,6 +193,19 @@ defmodule Lua.Compiler.Bytecode do defp encode({:power, dest, a, b, hint_a, hint_b}), do: {:ok, {@op_power, dest, a, b, hint_a, hint_b}} defp encode({:negate, dest, src, hint}), do: {:ok, {@op_negate, dest, src, hint}} + # Bitwise instructions carry the same per-operand hint tuples as + # arithmetic. The dispatcher inlines a two-integer fast path for + # band/bor/bxor and bridges to `Executor.dispatcher_bitwise/7` / + # `dispatcher_bnot/5` for non-integer operands, metamethods, and all + # shifts — so on-disk bytecode preserves both the int64 wrap and the + # hint suffix on `attempt to perform bitwise operation` errors. + defp encode({:bitwise_and, dest, a, b, hint_a, hint_b}), do: {:ok, {@op_bitwise_and, dest, a, b, hint_a, hint_b}} + defp encode({:bitwise_or, dest, a, b, hint_a, hint_b}), do: {:ok, {@op_bitwise_or, dest, a, b, hint_a, hint_b}} + defp encode({:bitwise_xor, dest, a, b, hint_a, hint_b}), do: {:ok, {@op_bitwise_xor, dest, a, b, hint_a, hint_b}} + defp encode({:shift_left, dest, a, b, hint_a, hint_b}), do: {:ok, {@op_shift_left, dest, a, b, hint_a, hint_b}} + defp encode({:shift_right, dest, a, b, hint_a, hint_b}), do: {:ok, {@op_shift_right, dest, a, b, hint_a, hint_b}} + defp encode({:bitwise_not, dest, src, hint}), do: {:ok, {@op_bitwise_not, dest, src, hint}} + defp encode({:less_than, dest, a, b}), do: {:ok, {@op_less_than, dest, a, b}} defp encode({:less_equal, dest, a, b}), do: {:ok, {@op_less_equal, dest, a, b}} defp encode({:greater_than, dest, a, b}), do: {:ok, {@op_greater_than, dest, a, b}} @@ -276,19 +301,23 @@ defmodule Lua.Compiler.Bytecode do do: {:ok, {@op_set_field, table_reg, name, value_reg, name_hint}} # `:set_list` with a positive integer `count` is the table-constructor - # form (`{1, 2, 3}`). Two adjacent shapes stay on the interpreter: + # form (`{1, 2, 3}`). # - # - `{:multi, _}` absorbs a multi-return call's results (e.g. - # `{f(), 1}`); it's covered by B5c-v2 alongside the rest of the - # multi-return machinery. + # - `{:multi, init_count}` absorbs a multi-return call's results (e.g. + # `{f(), 1}`): the dispatcher folds `init_count` with + # `state.multi_return_count` and reuses the same `set_list_pairs` + # machinery, mirroring the interpreter's `{:multi, _}` clause. # - `count == 0` is the interpreter's "consume `multi_return_count` # trailing values" sentinel. Current codegen never emits it from a # literal constructor, but encoding it as a no-op would silently # diverge from the interpreter if codegen ever did. The guard makes - # that contract explicit. + # that contract explicit — it stays on the interpreter. defp encode({:set_list, table_reg, start, count, offset}) when is_integer(count) and count > 0, do: {:ok, {@op_set_list, table_reg, start, count, offset}} + defp encode({:set_list, table_reg, start, {:multi, init_count}, offset}), + do: {:ok, {@op_set_list_multi, table_reg, start, init_count, offset}} + defp encode({:length, dest, source}), do: {:ok, {@op_length, dest, source}} # `:numeric_for`, `:while_loop`, `:repeat_loop`, `:generic_for` each @@ -360,10 +389,9 @@ defmodule Lua.Compiler.Bytecode do defp encode(:break), do: {:ok, {@op_break}} # Anything else — `:goto` / `:label` (label-resolution semantics not - # ported to the dispatcher), `:set_global` (codegen vestige; modern - # codegen uses `:set_field` on `_ENV` instead), and the bitwise family - # (out of scope for B5c-v2; their own follow-up) — stays on the - # interpreter. + # ported to the dispatcher's flat tuple model) and `:set_global` (codegen + # vestige; modern codegen uses `:set_field` on `_ENV` instead) — stays on + # the interpreter. # # `:source_line` is stripped upstream in `encode_list/2`, so it never # reaches this clause table. @@ -428,4 +456,11 @@ defmodule Lua.Compiler.Bytecode do def op_repeat_loop, do: @op_repeat_loop def op_generic_for, do: @op_generic_for def op_close_upvalues, do: @op_close_upvalues + def op_bitwise_and, do: @op_bitwise_and + def op_bitwise_or, do: @op_bitwise_or + def op_bitwise_xor, do: @op_bitwise_xor + def op_shift_left, do: @op_shift_left + def op_shift_right, do: @op_shift_right + def op_bitwise_not, do: @op_bitwise_not + def op_set_list_multi, do: @op_set_list_multi end diff --git a/lib/lua/vm/dispatcher.ex b/lib/lua/vm/dispatcher.ex index 72eaa6f6..26500a89 100644 --- a/lib/lua/vm/dispatcher.ex +++ b/lib/lua/vm/dispatcher.ex @@ -104,6 +104,17 @@ defmodule Lua.VM.Dispatcher do @op_generic_for 51 @op_close_upvalues 52 + # Bitwise family plus the `:set_list` multi-return tail. Mirrors the + # `@op_*` block in `Lua.Compiler.Bytecode`; the integers must stay + # identical in both files. + @op_bitwise_and 53 + @op_bitwise_or 54 + @op_bitwise_xor 55 + @op_shift_left 56 + @op_shift_right 57 + @op_bitwise_not 58 + @op_set_list_multi 59 + @doc """ Execute a compiled prototype against `args` and `state`. """ @@ -152,13 +163,17 @@ defmodule Lua.VM.Dispatcher do end end + # The interpreter sizes register tuples with a +16 slack buffer + # (executor.ex:135) because codegen's `max_registers` undercounts the + # transient scratch slots a few constructs use — notably table + # constructors whose last element is a multi-return call (`{x, f()}`), + # which write through registers past the syntactic peak. The dispatcher + # mirrors that buffer so the same prototypes run safely once they + # compile; multi-return expansion still grows on demand beyond it. + @reg_slack 16 + defp init_regs(proto, args) do - # The interpreter sizes register tuples with a +16 buffer for - # multi-return expansion (`ensure_regs_capacity/2`). The - # dispatcher's `:call_one` always wants exactly one result and - # the codegen now honestly reports the peak register, so no - # buffer is needed at all here. - size = max(proto.max_registers, proto.param_count) + size = max(proto.max_registers, proto.param_count) + @reg_slack regs = Tuple.duplicate(nil, size) copy_args(regs, 0, args, proto.param_count) end @@ -416,6 +431,81 @@ defmodule Lua.VM.Dispatcher do regs = :erlang.setelement(dest + 1, regs, value) dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames) + # ── Bitwise ───────────────────────────────────────────────────── + # + # band/bor/bxor of two integers take a fast path, but still apply the + # `to_signed_int64` narrow the executor uses: a register can hold an + # integer outside [-2^63, 2^63-1] (e.g. a host int injected via + # `Lua.set!` / `Value.encode/2`, which do not narrow), and skipping + # the narrow would both diverge from the interpreter and leak an + # out-of-range integer back into Lua state. For two already-narrow + # int64s the narrow is a cheap range check that masks nothing. Any + # non-integer operand (incl. float-with-fraction, string-coercible, + # tref with `__band` etc.) bridges to `Executor.dispatcher_bitwise/7` + # so coercion, metamethod dispatch, and hint-suffixed error + # attribution all match the interpreter. Shifts and bnot have no + # profitable number-only fast path (shift amounts and pre-truncation + # values need `lua_shift_*` masking), so they always bridge. + + {@op_bitwise_and, dest, a, b, hint_a, hint_b} -> + va = :erlang.element(a + 1, regs) + vb = :erlang.element(b + 1, regs) + + if is_integer(va) and is_integer(vb) do + regs = :erlang.setelement(dest + 1, regs, Numeric.to_signed_int64(Bitwise.band(va, vb))) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames) + else + {value, state} = Executor.dispatcher_bitwise(:band, va, vb, state, proto, hint_a, hint_b) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames) + end + + {@op_bitwise_or, dest, a, b, hint_a, hint_b} -> + va = :erlang.element(a + 1, regs) + vb = :erlang.element(b + 1, regs) + + if is_integer(va) and is_integer(vb) do + regs = :erlang.setelement(dest + 1, regs, Numeric.to_signed_int64(Bitwise.bor(va, vb))) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames) + else + {value, state} = Executor.dispatcher_bitwise(:bor, va, vb, state, proto, hint_a, hint_b) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames) + end + + {@op_bitwise_xor, dest, a, b, hint_a, hint_b} -> + va = :erlang.element(a + 1, regs) + vb = :erlang.element(b + 1, regs) + + if is_integer(va) and is_integer(vb) do + regs = :erlang.setelement(dest + 1, regs, Numeric.to_signed_int64(Bitwise.bxor(va, vb))) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames) + else + {value, state} = Executor.dispatcher_bitwise(:bxor, va, vb, state, proto, hint_a, hint_b) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames) + end + + {@op_shift_left, dest, a, b, hint_a, hint_b} -> + va = :erlang.element(a + 1, regs) + vb = :erlang.element(b + 1, regs) + {value, state} = Executor.dispatcher_bitwise(:shl, va, vb, state, proto, hint_a, hint_b) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames) + + {@op_shift_right, dest, a, b, hint_a, hint_b} -> + va = :erlang.element(a + 1, regs) + vb = :erlang.element(b + 1, regs) + {value, state} = Executor.dispatcher_bitwise(:shr, va, vb, state, proto, hint_a, hint_b) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames) + + {@op_bitwise_not, dest, src, hint} -> + val = :erlang.element(src + 1, regs) + {value, state} = Executor.dispatcher_bnot(val, state, proto, hint) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames) + # ── Comparisons ───────────────────────────────────────────────── {@op_less_than, dest, a, b} -> @@ -794,8 +884,8 @@ defmodule Lua.VM.Dispatcher do state = Executor.dispatcher_set_field(table_val, name, value, state, proto, name_hint) dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames) - # `:set_list` runs only the integer-count form (see encoder). The - # multi-return form (`{:multi, _}`) was filtered upstream and never + # `:set_list` with a positive integer count is the table-constructor + # form. The `count == 0` sentinel was filtered upstream and never # reaches the dispatcher. {@op_set_list, table_reg, start, count, offset} -> {:tref, id} = :erlang.element(table_reg + 1, regs) @@ -807,6 +897,21 @@ defmodule Lua.VM.Dispatcher do dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames) + # `:set_list` multi-return tail (`{f(), 1}`): fold the static prefix + # `init_count` with the trailing values count the last multi-return + # call recorded in `state.multi_return_count`. Mirrors the + # interpreter's `{:multi, _}` clause exactly. + {@op_set_list_multi, table_reg, start, init_count, offset} -> + {:tref, id} = :erlang.element(table_reg + 1, regs) + total = init_count + state.multi_return_count + + state = + State.update_table(state, {:tref, id}, fn table -> + set_list_into_table(table, regs, start, total, offset, 0) + end) + + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames) + {@op_length, dest, source} -> value = :erlang.element(source + 1, regs) @@ -1354,11 +1459,17 @@ defmodule Lua.VM.Dispatcher do return_one(value, state, rest_frames) {:multi, base, -2} -> + # The expansion dest may sit past the statically reserved register + # range (a constructor tail like `{x, f()}` where the body never + # named that many locals). Grow first, mirroring the interpreter's + # `ensure_regs_capacity/2` at the post-call site. + regs = grow_regs(regs, base + 1) regs = :erlang.setelement(base + 1, regs, value) state = %{state | multi_return_count: 1} dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames) {:multi, base, n} when is_integer(n) and n > 1 -> + regs = grow_regs(regs, base + n) regs = :erlang.setelement(base + 1, regs, value) regs = pad_nils(regs, base + 1, n - 1) dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames) @@ -1414,11 +1525,10 @@ defmodule Lua.VM.Dispatcher do end defp init_callee_regs(callee_proto, src_regs, src_off, arg_count) do - # Same as `init_regs/2`: no buffer needed because the bytecode - # encoder rejects multi-return calls (which are the only thing - # the interpreter's +16 buffer absorbs) and codegen reports the - # honest peak register. - size = max(callee_proto.max_registers, callee_proto.param_count) + # Same +16 slack buffer as `init_regs/2` (executor.ex:1113): the callee + # body may use scratch registers past its reported `max_registers` for + # multi-return constructor tails. + size = max(callee_proto.max_registers, callee_proto.param_count) + @reg_slack regs = Tuple.duplicate(nil, size) copy_n = min(arg_count, callee_proto.param_count) copy_regs(src_regs, src_off, regs, 0, copy_n) diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index 8f930465..af4172dc 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -290,6 +290,65 @@ defmodule Lua.VM.Executor do try_unary_metamethod("__unm", val, state, fn -> safe_negate(val, 0, proto.source, hint, state) end) end + # Bitwise bridges mirror the `:bitwise_*` / `:shift_*` interpreter clauses + # (do_execute) with `line = 0`, matching how `dispatcher_binop/7` drops the + # line. The dispatcher inlines the two-integer fast path for band/bor/bxor + # and bridges here for everything else (non-integers, metamethods) and for + # all of shl/shr (no profitable inline fast path — shift semantics live in + # `lua_shift_left` / `lua_shift_right`). + @doc false + @spec dispatcher_bitwise(atom(), term(), term(), State.t(), term(), term(), term()) :: + {term(), State.t()} + def dispatcher_bitwise(:band, a, b, state, proto, hint_a, hint_b) do + src = proto.source + + try_binary_metamethod("__band", a, b, state, fn -> + Numeric.to_signed_int64(Bitwise.band(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state))) + end) + end + + def dispatcher_bitwise(:bor, a, b, state, proto, hint_a, hint_b) do + src = proto.source + + try_binary_metamethod("__bor", a, b, state, fn -> + Numeric.to_signed_int64(Bitwise.bor(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state))) + end) + end + + def dispatcher_bitwise(:bxor, a, b, state, proto, hint_a, hint_b) do + src = proto.source + + try_binary_metamethod("__bxor", a, b, state, fn -> + Numeric.to_signed_int64(Bitwise.bxor(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state))) + end) + end + + def dispatcher_bitwise(:shl, a, b, state, proto, hint_a, hint_b) do + src = proto.source + + try_binary_metamethod("__shl", a, b, state, fn -> + lua_shift_left(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state)) + end) + end + + def dispatcher_bitwise(:shr, a, b, state, proto, hint_a, hint_b) do + src = proto.source + + try_binary_metamethod("__shr", a, b, state, fn -> + lua_shift_right(to_integer!(a, 0, src, hint_a, state), to_integer!(b, 0, src, hint_b, state)) + end) + end + + @doc false + @spec dispatcher_bnot(term(), State.t(), term(), term()) :: {term(), State.t()} + def dispatcher_bnot(val, state, proto, hint) do + src = proto.source + + try_unary_metamethod("__bnot", val, state, fn -> + Numeric.to_signed_int64(Bitwise.bnot(to_integer!(val, 0, src, hint, state))) + end) + end + @doc false @spec dispatcher_cmp(atom(), term(), term(), State.t(), term()) :: {term(), State.t()} def dispatcher_cmp(:less_than, a, b, state, proto) do diff --git a/test/lua/compiler/bytecode_test.exs b/test/lua/compiler/bytecode_test.exs index 633704b0..10ce91ed 100644 --- a/test/lua/compiler/bytecode_test.exs +++ b/test/lua/compiler/bytecode_test.exs @@ -176,11 +176,26 @@ defmodule Lua.Compiler.BytecodeTest do assert result.bytecode == nil end - test ":set_list with {:multi, _} count falls back" do - # Same multi-return splicing shape, produced by codegen when a - # constructor's last element is `f()` (and so absorbs the call's - # full result list). The dispatcher only handles literal-count - # constructors. + test ":goto falls back (label-resolution semantics not ported)" do + # `:goto` / `:label` resolve via a runtime structural scan over the + # remaining instruction list; the dispatcher's flat-tuple model has + # no analogue yet, so the encoder forces fallback. + proto = %Prototype{ + instructions: [{:label, :done}, {:goto, :done}, {:return, 0, 0}], + max_registers: 1, + source: "test-synthetic" + } + + result = Bytecode.compile(proto) + assert result.bytecode == nil + end + end + + describe "set_list multi-return tail" do + test ":set_list with {:multi, _} count encodes" do + # Produced by codegen when a constructor's last element is `f()` + # (so it absorbs the call's full result list). The dispatcher folds + # the static prefix with `state.multi_return_count` at run time. proto = %Prototype{ instructions: [{:set_list, 0, 1, {:multi, 2}, 0}, {:return, 0, 0}], max_registers: 2, @@ -188,18 +203,22 @@ defmodule Lua.Compiler.BytecodeTest do } result = Bytecode.compile(proto) - assert result.bytecode == nil + assert is_tuple(result.bytecode) + assert elem(elem(result.bytecode, 0), 0) == Bytecode.op_set_list_multi() end end describe "cascade independence" do test "child prototype compiles even when sibling falls back" do - # `pure` is pure arithmetic (covered). `impure` uses bitwise AND - # which is not yet in dispatcher coverage (its own follow-up plan). + # `pure` is pure arithmetic (covered). `impure` uses a `goto`, which + # is not yet in dispatcher coverage (its own follow-up plan). proto = compile!(""" function pure(a, b) return a + b end - function impure(a, b) return a & b end + function impure() + ::top:: + goto top + end """) [pure_proto, impure_proto] = proto.prototypes @@ -208,12 +227,13 @@ defmodule Lua.Compiler.BytecodeTest do end test "deeply-nested function compiles even when its parent falls back" do - # The outer `make` uses bitwise AND (fallback), but the inner - # adder is a pure-arithmetic single-result function (compiles). + # The outer `make` uses a `goto` (fallback), but the inner adder is a + # pure-arithmetic single-result function (compiles). proto = compile!(""" function make() - local m = 1 & 0 + ::skip:: + goto skip local function add(a, b) return a + b end return add end @@ -227,6 +247,30 @@ defmodule Lua.Compiler.BytecodeTest do end end + describe "bitwise coverage" do + test "a whole function using `n & 1` compiles end-to-end" do + proto = + compile!(""" + function odd(n) return n & 1 end + """) + + [fn_proto] = proto.prototypes + assert is_tuple(fn_proto.bytecode) + end + + test "every bitwise op encodes (band/bor/bxor/shl/shr/bnot)" do + proto = + compile!(""" + function f(a, b) + return (a & b) | (a ~ b) | (a << b) | (a >> b) | (~a) + end + """) + + [fn_proto] = proto.prototypes + assert is_tuple(fn_proto.bytecode) + end + end + describe "edge cases" do test "an empty function body compiles" do # Empty body codegen emits `{:return, 0, 0}` which is the @@ -256,10 +300,17 @@ defmodule Lua.Compiler.BytecodeTest do end test "fallback returns a Prototype with bytecode: nil, never an error" do - # The encoder must not crash on any well-formed prototype. Bitwise - # operations stay on the interpreter (out of scope for B5c-v2), + # The encoder must not crash on any well-formed prototype. `goto` + # stays on the interpreter (label-resolution semantics not ported), # so use one to exercise the fallback path. - proto = compile!("function f(a, b) return a | b end") + proto = + compile!(""" + function f() + ::top:: + goto top + end + """) + [fn_proto] = proto.prototypes assert %Prototype{} = fn_proto assert fn_proto.bytecode == nil diff --git a/test/lua/compiler/max_registers_invariant_test.exs b/test/lua/compiler/max_registers_invariant_test.exs index 3e655761..cae074b3 100644 --- a/test/lua/compiler/max_registers_invariant_test.exs +++ b/test/lua/compiler/max_registers_invariant_test.exs @@ -3,12 +3,14 @@ defmodule Lua.Compiler.MaxRegistersInvariantTest do Pins the load-bearing invariant that `proto.max_registers` is large enough to hold every register the encoded bytecode references. - The dispatcher sizes its register tuple exactly to `max_registers` - (no `+16` safety buffer like the interpreter), so any register write - beyond that bound raises `:badarg` from `:erlang.setelement/3`. The - invariant is enforced by `Lua.Compiler.Codegen.record_peak/1`; this - test pins it across the existing compilable surface so regressions - surface in CI rather than as a runtime crash. + The dispatcher sizes its register tuple to `max_registers` plus a + fixed `+16` slack buffer (`@reg_slack`), so a small codegen undercount + is absorbed rather than crashing. Writes beyond that buffer still raise + `:badarg` from `:erlang.setelement/3`, so the invariant — that codegen + bounds every encoded register index — is what keeps us off the slack. + It is enforced by `Lua.Compiler.Codegen.record_peak/1`; this test pins + it across the existing compilable surface so undercounts surface in CI + rather than as a runtime crash once they exceed the buffer. The walker recurses into nested branch bodies (`:test`) and nested prototypes so the bound is checked at every level. @@ -89,6 +91,18 @@ defmodule Lua.Compiler.MaxRegistersInvariantTest do # watermark can equal max_registers (one past the last live slot), so # it must not count toward the max-register bound. op == Bytecode.op_close_upvalues() -> [] + # Bitwise opcodes: {tag, dest, a, b, hint_a, hint_b} read a/b and + # write dest. `bitwise_not` is unary: {tag, dest, src, hint}. + op == Bytecode.op_bitwise_and() -> [1, 2, 3] + op == Bytecode.op_bitwise_or() -> [1, 2, 3] + op == Bytecode.op_bitwise_xor() -> [1, 2, 3] + op == Bytecode.op_shift_left() -> [1, 2, 3] + op == Bytecode.op_shift_right() -> [1, 2, 3] + op == Bytecode.op_bitwise_not() -> [1, 2] + # set_list_multi: {tag, table_reg, start, init_count, offset}. The + # multi-return values occupy start..top at runtime, but the only + # syntactic register operands are table_reg and the start slot. + op == Bytecode.op_set_list_multi() -> [1, 2] true -> raise "register_positions/1 is missing a case for opcode #{inspect(op)}" end end @@ -241,6 +255,39 @@ defmodule Lua.Compiler.MaxRegistersInvariantTest do local z = x * y return z end + """}, + {"bitwise + shift + bnot", + """ + function f(a, b) + local x = a & b + local y = a | b + local z = a ~ b + local s = a << b + local r = a >> b + return x + y + z + s + r + ~a + end + """}, + {"constructor absorbing a call tail (set_list_multi)", + """ + function pair() return 10, 20 end + function build() + local t = {1, pair()} + return t[1], t[2], t[3] + end + """}, + {"constructor absorbing a vararg tail (set_list_multi)", + """ + function build(...) + local t = {1, ...} + return t[1], t[2], t[3] + end + """}, + {"constructor with a bare vararg tail (set_list_multi, init_count==0)", + """ + function build(...) + local t = {...} + return t[1], t[2], t[3] + end """} ] diff --git a/test/lua/vm/dispatcher_test.exs b/test/lua/vm/dispatcher_test.exs index 99fad540..35fac605 100644 --- a/test/lua/vm/dispatcher_test.exs +++ b/test/lua/vm/dispatcher_test.exs @@ -26,8 +26,10 @@ defmodule Lua.VM.DispatcherTest do alias Lua.Parser alias Lua.VM alias Lua.VM.Dispatcher + alias Lua.VM.Numeric alias Lua.VM.State alias Lua.VM.Stdlib + alias Lua.VM.TypeError defp run!(code) do {:ok, ast} = Parser.parse(code) @@ -37,10 +39,39 @@ defmodule Lua.VM.DispatcherTest do {proto, results} end + # Like `run!/1` but seeds host-supplied globals before executing. Lets a + # test inject a raw value (e.g. an integer outside int64) the way + # `Lua.set!` / `Value.encode/2` would, since neither narrows. + defp run_with_globals!(code, globals) do + {:ok, ast} = Parser.parse(code) + {:ok, proto} = Compiler.compile(ast, source: "test.lua") + + state = + Enum.reduce(globals, Stdlib.install(State.new()), fn {name, value}, acc -> + State.set_global(acc, to_string(name), value) + end) + + {:ok, results, _state} = VM.execute(proto, state) + {proto, results} + end + # Pulls out the first sub-prototype — the one wrapping a `function` # body the dispatcher is expected to run. defp first_sub(%Prototype{prototypes: [fp | _]}), do: fp + # True if `op` is encoded anywhere in the prototype tree rooted at + # `proto`. Used to confirm a specific opcode reached bytecode without + # assuming which (sub-)prototype owns it. + defp encodes_op?(%Prototype{} = proto, op) do + own = + case proto.bytecode do + nil -> false + bc -> bc |> Tuple.to_list() |> Enum.any?(&(:erlang.element(1, &1) == op)) + end + + own or Enum.any?(proto.prototypes, &encodes_op?(&1, op)) + end + describe "arithmetic opcodes (dispatcher-compiled body)" do test ":add — integer fast path" do {proto, results} = @@ -143,6 +174,325 @@ defmodule Lua.VM.DispatcherTest do end end + describe "bitwise opcodes (dispatcher-compiled body)" do + test ":bitwise_and — integer fast path" do + {proto, results} = + run!(""" + function f(a, b) return a & b end + return f(6, 3) + """) + + assert first_sub(proto).bytecode + assert results == [2] + end + + test ":bitwise_or — integer fast path" do + {proto, results} = + run!(""" + function f(a, b) return a | b end + return f(6, 3) + """) + + assert first_sub(proto).bytecode + assert results == [7] + end + + test ":bitwise_xor — integer fast path" do + {proto, results} = + run!(""" + function f(a, b) return a ~ b end + return f(6, 3) + """) + + assert first_sub(proto).bytecode + assert results == [5] + end + + test ":bitwise_not" do + {proto, results} = + run!(""" + function f(a) return ~a end + return f(0) + """) + + assert first_sub(proto).bytecode + assert results == [-1] + end + + test ":shift_left" do + {proto, results} = + run!(""" + function f(a, b) return a << b end + return f(1, 4) + """) + + assert first_sub(proto).bytecode + assert results == [16] + end + + test ":shift_right" do + {proto, results} = + run!(""" + function f(a, b) return a >> b end + return f(256, 4) + """) + + assert first_sub(proto).bytecode + assert results == [16] + end + + test "negative shift count flips direction (a << -n == a >> n)" do + {proto, results} = + run!(""" + function f(a, b) return a << b end + return f(256, -4) + """) + + assert first_sub(proto).bytecode + assert results == [16] + end + + test "shift by >= 64 yields 0" do + {proto, results} = + run!(""" + function f(a, b) return a << b end + return f(1, 64) + """) + + assert first_sub(proto).bytecode + assert results == [0] + end + + test ">> is a logical (unsigned) shift on a negative operand" do + {proto, results} = + run!(""" + function f(a, b) return a >> b end + return f(-1, 1) + """) + + assert first_sub(proto).bytecode + assert results == [9_223_372_036_854_775_807] + end + + test "float with integer value coerces (slow path)" do + {proto, results} = + run!(""" + function f(a, b) return a & b end + return f(6.0, 3.0) + """) + + assert first_sub(proto).bytecode + assert results == [2] + end + + test "__band metamethod (slow-path bridge)" do + {proto, results} = + run!(""" + function f(t, n) return t & n end + local mt = {__band = function(_, n) return n + 100 end} + local t = setmetatable({}, mt) + return f(t, 5) + """) + + assert first_sub(proto).bytecode + assert results == [105] + end + + test "__bor metamethod (slow-path bridge)" do + {proto, results} = + run!(""" + function f(t, n) return t | n end + local mt = {__bor = function(_, n) return n + 200 end} + local t = setmetatable({}, mt) + return f(t, 5) + """) + + assert first_sub(proto).bytecode + assert results == [205] + end + + test "__bxor metamethod (slow-path bridge)" do + {proto, results} = + run!(""" + function f(t, n) return t ~ n end + local mt = {__bxor = function(_, n) return n + 300 end} + local t = setmetatable({}, mt) + return f(t, 5) + """) + + assert first_sub(proto).bytecode + assert results == [305] + end + + test "__shr metamethod (slow-path bridge)" do + {proto, results} = + run!(""" + function f(t, n) return t >> n end + local mt = {__shr = function(_, n) return n + 7 end} + local t = setmetatable({}, mt) + return f(t, 5) + """) + + assert first_sub(proto).bytecode + assert results == [12] + end + + test "matches the interpreter for an int64-boundary AND" do + code = """ + function f(a, b) return a & b end + return f(-1, 9223372036854775807) + """ + + {proto, results} = run!(code) + assert first_sub(proto).bytecode + assert results == [9_223_372_036_854_775_807] + end + + test "narrows an out-of-int64 integer operand to match the interpreter" do + # A host can inject an integer outside [-2^63, 2^63-1] via `Lua.set!` + # (Value.encode/2 passes host ints through unnarrowed). The integer + # fast path must still narrow its result, matching the interpreter's + # `Numeric.to_signed_int64` wrap — otherwise it both diverges and + # leaks an out-of-range integer back into Lua state. + big = Bitwise.bsl(1, 64) + + for {op, mask} <- [{"&", 1}, {"|", 1}, {"~", 1}] do + code = """ + function f(a, b) return a #{op} b end + return f(big, #{mask}) + """ + + {proto, [result]} = run_with_globals!(code, big: big) + assert first_sub(proto).bytecode + + expected = + case op do + "&" -> Numeric.to_signed_int64(Bitwise.band(big, mask)) + "|" -> Numeric.to_signed_int64(Bitwise.bor(big, mask)) + "~" -> Numeric.to_signed_int64(Bitwise.bxor(big, mask)) + end + + assert result == expected + assert result >= -Bitwise.bsl(1, 63) and result <= Bitwise.bsl(1, 63) - 1 + end + end + + test "numeric string operand coerces (slow-path bridge)" do + {proto, results} = + run!(""" + function f(a, b) return a & b end + return f("6", 3) + """) + + assert first_sub(proto).bytecode + assert results == [2] + end + + test "float with a fractional part raises (no integer representation)" do + assert_raise TypeError, ~r/no integer representation/, fn -> + run!(""" + function f(a, b) return a & b end + return f(3.5, 1) + """) + end + end + + test "non-numeric string operand raises (bitwise on a string value)" do + assert_raise TypeError, ~r/bitwise operation on a string/, fn -> + run!(""" + function f(a, b) return a & b end + return f("x", 1) + """) + end + end + + test "__shl metamethod (slow-path bridge)" do + {proto, results} = + run!(""" + function f(t, n) return t << n end + local mt = {__shl = function(_, n) return n + 1 end} + local t = setmetatable({}, mt) + return f(t, 5) + """) + + assert first_sub(proto).bytecode + assert results == [6] + end + + test "__bnot metamethod (slow-path bridge)" do + {proto, results} = + run!(""" + function f(t) return ~t end + local mt = {__bnot = function(_) return 42 end} + local t = setmetatable({}, mt) + return f(t) + """) + + assert first_sub(proto).bytecode + assert results == [42] + end + end + + describe "set_list multi-return tail (dispatcher-compiled body)" do + test "constructor absorbing a multi-return call matches the interpreter" do + {proto, results} = + run!(""" + function pair() return 10, 20 end + function build() + local t = {1, pair()} + return t[1], t[2], t[3] + end + return build() + """) + + assert encodes_op?(proto, Bytecode.op_set_list_multi()) + assert results == [1, 10, 20] + end + + test "constructor absorbing a vararg tail matches the interpreter" do + {proto, results} = + run!(""" + function build(...) + local t = {1, ...} + return t[1], t[2], t[3] + end + return build(10, 20) + """) + + assert encodes_op?(proto, Bytecode.op_set_list_multi()) + assert results == [1, 10, 20] + end + + test "constructor with a bare vararg tail (init_count==0) matches the interpreter" do + {proto, results} = + run!(""" + function build(...) + local t = {...} + return t[1], t[2], t[3] + end + return build(10, 20, 30) + """) + + assert encodes_op?(proto, Bytecode.op_set_list_multi()) + assert results == [10, 20, 30] + end + + test "constructor with a tail call returning zero values matches the interpreter" do + {proto, results} = + run!(""" + function none() end + function build() + local t = {1, none()} + return #t, t[1] + end + return build() + """) + + assert encodes_op?(proto, Bytecode.op_set_list_multi()) + assert results == [1, 1] + end + end + describe "comparison opcodes" do test ":less_than with numbers" do {proto, results} =