From b6eb7ec9944ea498e5a27f3a6b4716240ec24bd4 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Wed, 15 Jul 2026 09:05:33 -0700 Subject: [PATCH 1/2] errors: unify public runtime exceptions around Lua.RuntimeException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public exception surface leaked VM internals: Lua.call_function/3 returned raw Lua.VM.{RuntimeError,TypeError,ArgumentError,...} structs, inconsistently suffixed `*Error` and namespaced under the implementation detail Lua.VM. Make the public surface exactly two exceptions, both `*Exception`: * Lua.CompilerException — compile/parse failures * Lua.RuntimeException — every runtime failure The five Lua.VM.*Error structs are now internal (@moduledoc false): they are wrapped into Lua.RuntimeException before crossing any API boundary, so they never surface raw and are no longer documented as public. They stay under Lua.VM (the correct home for internal VM error types), which leaves the ~219 stdlib raise sites untouched. Lua.RuntimeException grows two fields so callers can still discriminate: * :kind — :error | :type | :argument | :assertion | :internal * :value — the raised Lua value, exactly as pcall hands it back (§6.1) (:original still carries the internal VM struct for deep inspection.) call_function/3 now returns {:error, %Lua.RuntimeException{}, lua} uniformly. Its :value now matches in-Lua pcall for string errors (both give ":1: boom"); previously it returned the un-prefixed "boom". --- CHANGELOG.md | 30 ++++--- lib/lua.ex | 40 ++++++--- lib/lua/runtime_exception.ex | 28 +++++- lib/lua/vm/argument_error.ex | 67 ++++++-------- lib/lua/vm/assertion_error.ex | 15 ++-- lib/lua/vm/internal_error.ex | 12 ++- lib/lua/vm/runtime_error.ex | 25 +++--- lib/lua/vm/type_error.ex | 24 ++--- test/lua/call_function_error_value_test.exs | 89 ++++++++++++------- test/lua/runtime_exception_test.exs | 81 +++++++++++++++++ test/lua/vm/pcall_state_preservation_test.exs | 4 +- 11 files changed, 279 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ada4da91..e755806b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,25 +73,35 @@ is in the [`1.0.0-rc.0`](#100-rc0---2026-05-26) entry below. shim that always returned `false` and was imported into every `use Lua.API` module; the VM has no MFA references. Remove any `when is_mfa(value)` clauses (they never matched). +- **The public runtime exception is now solely `Lua.RuntimeException`.** The + five internal VM error structs — `Lua.VM.RuntimeError`, `Lua.VM.TypeError`, + `Lua.VM.ArgumentError`, `Lua.VM.AssertionError`, `Lua.VM.InternalError` — are + no longer part of the public surface. They are wrapped into + `Lua.RuntimeException` before crossing any API boundary, so the whole public + exception surface is just `Lua.RuntimeException` (runtime failures) and + `Lua.CompilerException` (compile-time failures). To discriminate a runtime + failure, read the wrapper's new `:kind` field + (`:error | :type | :argument | :assertion | :internal`); the underlying VM + struct remains available on `:original`. - The public `{:error, _}`-returning APIs now hand back the exception struct uniformly, instead of a pre-rendered message string, so callers own rendering (`Exception.message/1`) and can pattern-match the concrete error. - `Lua.call_function/3` returns `{:error, exception, lua}` where `exception` - is the VM struct (`Lua.VM.RuntimeError`, `Lua.VM.TypeError`, - `Lua.VM.ArgumentError`, …). The raised Lua value (`error(42)`, a table, - `nil`, `false`) is preserved on the struct's `:value`, matching what - `pcall` hands back inside Lua. Code matching on a string reason should - switch to the struct (or call `Exception.message/1` on it). + is always a `Lua.RuntimeException`. The raised Lua value (`error(42)`, a + table, `nil`, `false`) is preserved on `:value`, matching what `pcall` + hands back inside Lua, with the category on `:kind` and the underlying VM + struct on `:original`. Code matching on a string reason should switch to + the exception (or call `Exception.message/1` on it). - `Lua.parse_chunk/1` returns `{:error, %Lua.CompilerException{}}` instead of `{:error, [String.t()]}`. Call `Exception.message/1` to render the full, human-readable report; the `:errors` field carries the bare, ANSI-free messages for programmatic inspection. - Lua exceptions render their message lazily at `Exception.message/1` call - time, so the `:message` **struct field** is removed from - `Lua.VM.RuntimeError`, `Lua.VM.TypeError`, and `Lua.VM.AssertionError`, and - is `nil` on the `Lua.RuntimeException` wrapper when it wraps a VM exception. - Read the message through `Exception.message/1` (the idiomatic accessor) - rather than the `e.message` field. Likewise `Lua.CompilerException`'s + time, so the `:message` **struct field** is `nil` on the + `Lua.RuntimeException` wrapper when it wraps an internal VM exception (and on + the internal VM structs themselves). Read the message through + `Exception.message/1` (the idiomatic accessor) rather than the `e.message` + field. Likewise `Lua.CompilerException`'s `:errors` field now holds bare, ANSI-free messages rather than the fully formatted (and previously ANSI-colored) diagnostics — the rich report moved to `Exception.message/1`. This is what lets the ANSI gate hold at output time diff --git a/lib/lua.ex b/lib/lua.ex index 057b9d20..0c72526b 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -708,8 +708,9 @@ defmodule Lua do ## Errors When the function raises, `call_function/3` returns `{:error, exception, - lua}`, where `exception` is the Lua VM exception struct — a - `Lua.VM.RuntimeError`, `Lua.VM.TypeError`, `Lua.VM.ArgumentError`, etc. Call + lua}`, where `exception` is a `Lua.RuntimeException`. Read `:kind` + (`:error | :type | :argument | :assertion | :internal`) to discriminate the + failure, and `:original` to reach the internal VM exception. Call `Exception.message/1` yourself to render it; unlike the raising `call_function!/3`, the boundary does *not* pre-render it (no ANSI, no `at ::` header, no `Suggestion:` block). @@ -719,8 +720,8 @@ defmodule Lua do iex> Exception.message(exception) =~ "boom" true - The raised Lua value is preserved on the struct — exactly what `pcall` hands - back inside Lua. `error(42)` carries `42` on the `RuntimeError`'s `:value`: + The raised Lua value is preserved on `:value` — exactly what `pcall` hands + back inside Lua. `error(42)` carries `42`: iex> {[ref], lua} = Lua.eval!(Lua.new(), "return function() error(42) end", decode: false) iex> {:error, exception, _lua} = Lua.call_function(lua, ref, []) @@ -735,25 +736,36 @@ defmodule Lua do end # `call_function/3` is a programmatic protected-call boundary. Its `:error` - # payload is the VM exception struct itself, carried out verbatim so callers - # own the rendering (`Exception.message/1`) and can pattern-match on the - # concrete error struct. In-Lua `pcall`/`xpcall` still project the raw §6.1 - # value via `ProtectedCall.error_value/1`; this boundary deliberately does - # not. The raising variant `call_function!/3` re-raises the same exception - # through `Lua.RuntimeException` to get the rich `ErrorFormatter` render. + # payload is always the public `Lua.RuntimeException`: the internal VM + # exception is wrapped so callers pattern-match one type, read the raised Lua + # value off `:value` and the category off `:kind`, and render with + # `Exception.message/1`. In-Lua `pcall`/`xpcall` still project the raw §6.1 + # value via `ProtectedCall.error_value/1` inside the VM; they never reach + # this boundary. defp finish_call(%__MODULE__{} = lua, {:ok, results, new_state}) do {:ok, results, %{lua | state: new_state}} end - defp finish_call(%__MODULE__{} = lua, {:error, e, new_state}) when is_exception(e) do + # Already a host-facing exception (a nested eval, or a compiler error from + # `load`) — carry it out unchanged rather than re-wrapping. + defp finish_call(%__MODULE__{} = lua, {:error, %Lua.RuntimeException{} = e, new_state}) do + {:error, e, %{lua | state: new_state}} + end + + defp finish_call(%__MODULE__{} = lua, {:error, %Lua.CompilerException{} = e, new_state}) do {:error, e, %{lua | state: new_state}} end + defp finish_call(%__MODULE__{} = lua, {:error, e, new_state}) when is_exception(e) do + {:error, Lua.RuntimeException.exception(e), %{lua | state: new_state}} + end + # Defensive: every `resolve_and_call/3` error path yields an exception today. - # If a bare Lua value ever reaches here, wrap it so the boundary's contract - # stays uniform — the `:error` payload is always an exception struct. + # If a bare Lua value ever reaches here, wrap it as an `error()` runtime + # exception so the boundary's contract stays uniform. defp finish_call(%__MODULE__{} = lua, {:error, reason, new_state}) do - {:error, %RuntimeError{value: reason}, %{lua | state: new_state}} + exception = Lua.RuntimeException.exception(RuntimeError.exception(value: reason)) + {:error, exception, %{lua | state: new_state}} end # Resolves `name`/`func` to a callable and invokes it under protection. diff --git a/lib/lua/runtime_exception.ex b/lib/lua/runtime_exception.ex index 399b327a..d7a421c2 100644 --- a/lib/lua/runtime_exception.ex +++ b/lib/lua/runtime_exception.ex @@ -14,18 +14,26 @@ defmodule Lua.RuntimeException do * `:message` — pre-rendered string for terminal-independent errors, or `nil` when rendered lazily from `:original` * `:original` — the underlying VM error term + * `:kind` — the category of failure: `:error` (an explicit `error()` + call), `:type`, `:argument`, `:assertion`, or `:internal`; `nil` for + host-side API errors that don't originate from a Lua value + * `:value` — the raised Lua value (per §6.1), as `pcall`/`xpcall` + would hand it back; `nil` when there is no Lua-side value * `:state` — the internal VM state at the point of failure * `:line` — line number where the error was raised * `:source` — source name (filename or the default ``) * `:call_stack` — list of Lua frames at failure """ alias Lua.Util + alias Lua.VM.ProtectedCall @runtime_prefix "Lua runtime error: " + @type kind :: :error | :type | :argument | :assertion | :internal + @type t :: %__MODULE__{} - defexception [:message, :original, :state, :line, :source, :call_stack] + defexception [:message, :original, :kind, :value, :state, :line, :source, :call_stack] @impl true def exception({:lua_error, error, _state}) do @@ -57,9 +65,12 @@ defmodule Lua.RuntimeException do def exception(error) do # Copy structured fields off VM exceptions (TypeError, RuntimeError, - # AssertionError) so consumers can pattern-match on `:line` / `:source` - # without having to re-parse the message string. + # AssertionError, ArgumentError, InternalError) so consumers can + # pattern-match on `:kind` / `:value` / `:line` / `:source` without having + # to re-parse the message string. `:kind`/`:value` come back `nil` for + # arbitrary Elixir exceptions, which carry no Lua-side value. {line, source, call_stack} = extract_context(error) + kind = kind(error) # `:message` is left nil so `message/1` renders the wrapped error lazily. # The inner VM exceptions gate ANSI on `IO.ANSI.enabled?/0` at render time @@ -67,6 +78,8 @@ defmodule Lua.RuntimeException do # codes from the TTY-attached VM process and leak them into log sinks. %__MODULE__{ original: error, + kind: kind, + value: kind && ProtectedCall.error_value(error), line: line, source: source, call_stack: call_stack @@ -99,6 +112,15 @@ defmodule Lua.RuntimeException do defp extract_context(_), do: {nil, nil, nil} + # Classify a wrapped VM exception into its user-facing category. Arbitrary + # Elixir exceptions (no Lua-side value) return `nil`. + defp kind(%Lua.VM.RuntimeError{}), do: :error + defp kind(%Lua.VM.TypeError{}), do: :type + defp kind(%Lua.VM.ArgumentError{}), do: :argument + defp kind(%Lua.VM.AssertionError{}), do: :assertion + defp kind(%Lua.VM.InternalError{}), do: :internal + defp kind(_), do: nil + defp format_function([], function), do: "#{function}()" defp format_function(scope, function) do diff --git a/lib/lua/vm/argument_error.ex b/lib/lua/vm/argument_error.ex index 3325073e..12efb0b1 100644 --- a/lib/lua/vm/argument_error.ex +++ b/lib/lua/vm/argument_error.ex @@ -1,45 +1,30 @@ defmodule Lua.VM.ArgumentError do - @moduledoc """ - Raised when a function is called with invalid arguments. - - This exception provides standardized error messages for bad arguments across - all Lua standard library functions. - - ## Fields - - - `:function_name` - The fully qualified function name (e.g., "string.rep") - - `:arg_num` - The argument number (1-based) - - `:expected` - What type or value was expected (e.g., "number", "string") - - `:got` - What was actually received (optional, e.g., "nil", "boolean") - - `:details` - Additional details about the error (optional) - - `:line` - Source line where the call originated (auto-populated from - `Lua.VM.Executor.current_position/0` when not given explicitly) - - `:source` - Source name where the call originated (auto-populated) - - `:call_stack` - Call stack frames at the raise site (default `[]`) - - ## Examples - - # Basic type error — line/source filled in automatically when raised - # from inside a Lua execution. - raise ArgumentError, - function_name: "string.rep", - arg_num: 2, - expected: "number" - - # With actual type received - raise ArgumentError, - function_name: "string.sub", - arg_num: 2, - expected: "number", - got: "string" - - # With additional details - raise ArgumentError, - function_name: "string.char", - arg_num: 1, - expected: "number", - details: "value out of range" - """ + @moduledoc false + + # Internal VM exception. Never surfaces to the host directly — it is wrapped + # into the public `Lua.RuntimeException` (kind: `:argument`) at the API + # boundary, which projects `raw_message/1` onto `:value`. + # + # Raised when a function is called with invalid arguments; provides + # standardized "bad argument #N to 'F'" messages across all Lua standard + # library functions. + # + # Fields: + # + # - `:function_name` - fully qualified function name (e.g., "string.rep") + # - `:arg_num` - the argument number (1-based) + # - `:expected` - what type or value was expected ("number", "string", …) + # - `:got` - what was actually received (optional, "nil", "boolean", …) + # - `:details` - additional details about the error (optional) + # - `:line` / `:source` - call origin, auto-populated from + # `Lua.VM.Executor.current_position/0` when not given explicitly + # - `:call_stack` - call stack frames at the raise site (default `[]`) + # + # Examples: + # + # raise ArgumentError, function_name: "string.rep", arg_num: 2, expected: "number" + # raise ArgumentError, function_name: "string.sub", arg_num: 2, expected: "number", got: "string" + # raise ArgumentError, function_name: "string.char", arg_num: 1, expected: "number", details: "value out of range" alias Lua.VM.RuntimeError diff --git a/lib/lua/vm/assertion_error.ex b/lib/lua/vm/assertion_error.ex index 0ce03561..a89f534b 100644 --- a/lib/lua/vm/assertion_error.ex +++ b/lib/lua/vm/assertion_error.ex @@ -1,11 +1,14 @@ defmodule Lua.VM.AssertionError do - @moduledoc """ - Raised by the Lua `assert()` function when the condition is falsy. + @moduledoc false - When raised without explicit `:line` / `:source` opts, `exception/1` - populates them from the calling Lua source position via - `Lua.VM.Executor.current_position/0`. - """ + # Internal VM exception. Never surfaces to the host directly — it is wrapped + # into the public `Lua.RuntimeException` (kind: `:assertion`) at the API + # boundary. + # + # Raised by the Lua `assert()` function when the condition is falsy. When + # raised without explicit `:line` / `:source` opts, `exception/1` populates + # them from the calling Lua source position via + # `Lua.VM.Executor.current_position/0`. alias Lua.VM.ErrorFormatter diff --git a/lib/lua/vm/internal_error.ex b/lib/lua/vm/internal_error.ex index 0702d8b5..7a1dc7a6 100644 --- a/lib/lua/vm/internal_error.ex +++ b/lib/lua/vm/internal_error.ex @@ -1,8 +1,12 @@ defmodule Lua.VM.InternalError do - @moduledoc """ - Raised for internal VM errors: bad native function returns, - unimplemented instructions, and other invariant violations. - """ + @moduledoc false + + # Internal VM exception. Never surfaces to the host directly — it is wrapped + # into the public `Lua.RuntimeException` (kind: `:internal`) at the API + # boundary. + # + # Raised for internal VM errors: bad native function returns, unimplemented + # instructions, and other invariant violations. defexception [:value, :message] diff --git a/lib/lua/vm/runtime_error.ex b/lib/lua/vm/runtime_error.ex index 71684294..9d4edda8 100644 --- a/lib/lua/vm/runtime_error.ex +++ b/lib/lua/vm/runtime_error.ex @@ -1,17 +1,18 @@ defmodule Lua.VM.RuntimeError do - @moduledoc """ - Raised by the Lua `error()` function. + @moduledoc false - Carries the original Lua error value, which may be any Lua type (string, - number, table reference, etc.). - - When raised without explicit `:line` / `:source` opts (e.g. from a stdlib - bad-argument check), `exception/1` populates them from the calling Lua - source position via `Lua.VM.Executor.current_position/0`. That position - is stashed in the process dictionary at every native-call boundary, so - any raise site reachable from a Lua execution inherits the correct - attribution automatically. - """ + # Internal VM exception. Never surfaces to the host directly — it is wrapped + # into the public `Lua.RuntimeException` (kind: `:error`) at the API boundary. + # + # Raised by the Lua `error()` function. Carries the original Lua error value, + # which may be any Lua type (string, number, table reference, etc.). + # + # When raised without explicit `:line` / `:source` opts (e.g. from a stdlib + # bad-argument check), `exception/1` populates them from the calling Lua + # source position via `Lua.VM.Executor.current_position/0`. That position is + # stashed in the process dictionary at every native-call boundary, so any + # raise site reachable from a Lua execution inherits the correct attribution + # automatically. alias Lua.VM.ErrorFormatter alias Lua.VM.Value diff --git a/lib/lua/vm/type_error.ex b/lib/lua/vm/type_error.ex index c496c086..18774e6f 100644 --- a/lib/lua/vm/type_error.ex +++ b/lib/lua/vm/type_error.ex @@ -1,16 +1,18 @@ defmodule Lua.VM.TypeError do - @moduledoc """ - Raised when a Lua operation is applied to a value of the wrong type. + @moduledoc false - Examples: calling a nil value, calling a number, indexing a boolean. - - When raised without explicit `:line` / `:source` opts (e.g. from a stdlib - type check), `exception/1` populates them from the calling Lua source - position via `Lua.VM.Executor.current_position/0`. That position is - stashed in the process dictionary at every native-call boundary, so - any raise site reachable from a Lua execution inherits the correct - attribution automatically. - """ + # Internal VM exception. Never surfaces to the host directly — it is wrapped + # into the public `Lua.RuntimeException` (kind: `:type`) at the API boundary. + # + # Raised when a Lua operation is applied to a value of the wrong type. + # Examples: calling a nil value, calling a number, indexing a boolean. + # + # When raised without explicit `:line` / `:source` opts (e.g. from a stdlib + # type check), `exception/1` populates them from the calling Lua source + # position via `Lua.VM.Executor.current_position/0`. That position is stashed + # in the process dictionary at every native-call boundary, so any raise site + # reachable from a Lua execution inherits the correct attribution + # automatically. alias Lua.VM.ErrorFormatter diff --git a/test/lua/call_function_error_value_test.exs b/test/lua/call_function_error_value_test.exs index da0035f9..4a22ac5b 100644 --- a/test/lua/call_function_error_value_test.exs +++ b/test/lua/call_function_error_value_test.exs @@ -1,18 +1,22 @@ defmodule Lua.CallFunctionErrorValueTest do @moduledoc """ Pins the `Lua.call_function/3` protected-call boundary: its `{:error, - exception, _}` payload is the VM exception struct itself — a - `Lua.VM.RuntimeError`, `Lua.VM.TypeError`, or `Lua.VM.ArgumentError` — - carried out verbatim. The boundary does no rendering: callers own that by - calling `Exception.message/1`, and can pattern-match on the concrete struct - and its structured fields. The raw Lua value handed to `error()` (§6.1) is - preserved on the struct's `:value`, so pcall-parity data survives. - - The raising variant `call_function!/3` re-raises the same exception through - `Lua.RuntimeException`, keeping the rich `ErrorFormatter` render. + exception, _}` payload is always the public `Lua.RuntimeException`. The + internal VM exception is wrapped so callers pattern-match one type and read: + + * `:kind` — the category (`:error`, `:type`, `:argument`, …) + * `:value` — the Lua-facing error value exactly as `pcall`/`xpcall` + would hand it back (§6.1) + * `:original` — the underlying VM exception, for deep inspection of + structured fields (`error_kind`, `function_name`, …) + + The boundary does no rendering: callers own that by calling + `Exception.message/1`. The raising variant `call_function!/3` re-raises the + same `Lua.RuntimeException`, keeping the rich `ErrorFormatter` render. """ use ExUnit.Case, async: true + alias Lua.RuntimeException alias Lua.VM.ArgumentError alias Lua.VM.RuntimeError alias Lua.VM.TypeError @@ -22,40 +26,48 @@ defmodule Lua.CallFunctionErrorValueTest do {ref, lua} end - describe "call_function/3 returns the VM exception struct" do - test "error('boom') comes back as a RuntimeError carrying the raised value" do + describe "call_function/3 returns a Lua.RuntimeException" do + test "error('boom') comes back with kind :error and the §6.1 value" do {ref, lua} = fun!("function() error('boom') end") - assert {:error, %RuntimeError{value: "boom"} = error, %Lua{}} = + assert {:error, %RuntimeException{kind: :error, value: value} = error, %Lua{}} = Lua.call_function(lua, ref, []) + # String errors are position-prefixed by `error()`, matching pcall. + assert value =~ "boom" + assert %RuntimeError{value: "boom"} = error.original assert Exception.message(error) =~ "boom" end - test "a call-nil failure comes back as a TypeError struct" do + test "a call-nil failure comes back with kind :type" do {ref, lua} = fun!("function() local f = nil; f() end") - assert {:error, %TypeError{error_kind: :call_nil} = error, %Lua{}} = + assert {:error, %RuntimeException{kind: :type} = error, %Lua{}} = Lua.call_function(lua, ref, []) + assert %TypeError{error_kind: :call_nil} = error.original assert Exception.message(error) =~ "attempt to call a nil value" end - test "an index-nil failure comes back as a TypeError struct" do + test "an index-nil failure comes back with kind :type" do {ref, lua} = fun!("function() local t = nil; return t.x end") - assert {:error, %TypeError{error_kind: :index_non_table} = error, %Lua{}} = + assert {:error, %RuntimeException{kind: :type} = error, %Lua{}} = Lua.call_function(lua, ref, []) + assert %TypeError{error_kind: :index_non_table} = error.original assert Exception.message(error) =~ "attempt to index" end - test "a stdlib bad-argument failure comes back as an ArgumentError struct" do + test "a stdlib bad-argument failure comes back with kind :argument" do {ref, lua} = fun!(~S|function() for i in pairs("asdf") do end end|) - assert {:error, %ArgumentError{function_name: "pairs", arg_num: 1} = error, %Lua{}} = + assert {:error, %RuntimeException{kind: :argument, value: value} = error, %Lua{}} = Lua.call_function(lua, ref, []) + assert %ArgumentError{function_name: "pairs", arg_num: 1} = error.original + assert value =~ "bad argument #1 to 'pairs'" + message = Exception.message(error) assert message =~ "bad argument #1 to 'pairs'" assert message =~ "table expected" @@ -67,9 +79,11 @@ defmodule Lua.CallFunctionErrorValueTest do test "an undefined name names what was looked up, not the resolved nil" do {_, lua} = Lua.eval!(Lua.new(), "function foo() return 1 end") - assert {:error, %TypeError{error_kind: :call_nil} = error, %Lua{}} = + assert {:error, %RuntimeException{kind: :type} = error, %Lua{}} = Lua.call_function(lua, [:bar], []) + assert %TypeError{error_kind: :call_nil} = error.original + # Regression: previously reported the resolved value ("undefined # function 'nil'"). It must name the requested global instead. assert Exception.message(error) =~ "attempt to call a nil value (global 'bar')" @@ -78,7 +92,8 @@ defmodule Lua.CallFunctionErrorValueTest do test "an existing non-function value reports its type, not 'undefined'" do {_, lua} = Lua.eval!(Lua.new(), "x = 5") - assert {:error, %TypeError{} = error, %Lua{}} = Lua.call_function(lua, [:x], []) + assert {:error, %RuntimeException{kind: :type} = error, %Lua{}} = + Lua.call_function(lua, [:x], []) assert Exception.message(error) =~ "attempt to call a number value (global 'x')" end @@ -86,7 +101,8 @@ defmodule Lua.CallFunctionErrorValueTest do test "a nested path attributes to the final field" do {_, lua} = Lua.eval!(Lua.new(), "t = {}") - assert {:error, %TypeError{} = error, %Lua{}} = Lua.call_function(lua, [:t, :missing], []) + assert {:error, %RuntimeException{kind: :type} = error, %Lua{}} = + Lua.call_function(lua, [:t, :missing], []) assert Exception.message(error) =~ "attempt to call a nil value (field 'missing')" end @@ -97,7 +113,7 @@ defmodule Lua.CallFunctionErrorValueTest do {_, lua} = Lua.eval!(Lua.new(), "function foo() return 1 end") error = - assert_raise Lua.RuntimeException, fn -> + assert_raise RuntimeException, fn -> Lua.call_function!(lua, [:bar], []) end @@ -113,7 +129,7 @@ defmodule Lua.CallFunctionErrorValueTest do {_, lua} = Lua.eval!(Lua.new(), "function foo() return 1 end") error = - assert_raise Lua.RuntimeException, fn -> + assert_raise RuntimeException, fn -> Lua.call_function!(lua, [:bar], []) end @@ -121,30 +137,35 @@ defmodule Lua.CallFunctionErrorValueTest do end end - describe "call_function/3 preserves the raised Lua value on the struct (pcall parity)" do + describe "call_function/3 preserves the raised Lua value on :value (pcall parity)" do test "a table error object is preserved on :value" do {ref, lua} = fun!("function() error({code = 1}) end") - assert {:error, %RuntimeError{value: value}, %Lua{} = lua} = Lua.call_function(lua, ref, []) + assert {:error, %RuntimeException{kind: :error, value: value}, %Lua{} = lua} = + Lua.call_function(lua, ref, []) + assert Lua.decode!(lua, value) == [{"code", 1}] end test "a number error object is preserved on :value" do {ref, lua} = fun!("function() error(42) end") - assert {:error, %RuntimeError{value: 42}, %Lua{}} = Lua.call_function(lua, ref, []) + assert {:error, %RuntimeException{kind: :error, value: 42}, %Lua{}} = + Lua.call_function(lua, ref, []) end test "a nil error object is preserved on :value" do {ref, lua} = fun!("function() error(nil) end") - assert {:error, %RuntimeError{value: nil}, %Lua{}} = Lua.call_function(lua, ref, []) + assert {:error, %RuntimeException{kind: :error, value: nil}, %Lua{}} = + Lua.call_function(lua, ref, []) end test "a false error object is preserved on :value" do {ref, lua} = fun!("function() error(false) end") - assert {:error, %RuntimeError{value: false}, %Lua{}} = Lua.call_function(lua, ref, []) + assert {:error, %RuntimeException{kind: :error, value: false}, %Lua{}} = + Lua.call_function(lua, ref, []) end end @@ -153,7 +174,7 @@ defmodule Lua.CallFunctionErrorValueTest do {ref, lua} = fun!("function() error('boom') end") error = - assert_raise Lua.RuntimeException, fn -> + assert_raise RuntimeException, fn -> Lua.call_function!(lua, ref, []) end @@ -171,7 +192,7 @@ defmodule Lua.CallFunctionErrorValueTest do {ref, lua} = fun!(~S|function() pairs("asdf") end|) error = - assert_raise Lua.RuntimeException, fn -> + assert_raise RuntimeException, fn -> Lua.call_function!(lua, ref, []) end @@ -187,7 +208,7 @@ defmodule Lua.CallFunctionErrorValueTest do {ref, lua} = fun!("function() error('boom') end") error = - assert_raise Lua.RuntimeException, fn -> + assert_raise RuntimeException, fn -> Lua.call_function!(lua, ref, []) end @@ -198,7 +219,7 @@ defmodule Lua.CallFunctionErrorValueTest do {ref, lua} = fun!("function() local t = nil; return t.x end") error = - assert_raise Lua.RuntimeException, fn -> + assert_raise RuntimeException, fn -> Lua.call_function!(lua, ref, []) end @@ -217,7 +238,7 @@ defmodule Lua.CallFunctionErrorValueTest do {_, lua} = Lua.eval!(Lua.new(), code, source: "regression.lua") error = - assert_raise Lua.RuntimeException, fn -> + assert_raise RuntimeException, fn -> Lua.call_function!(lua, [:foo], []) end @@ -240,7 +261,7 @@ defmodule Lua.CallFunctionErrorValueTest do {_, lua} = Lua.eval!(Lua.new(), code, source: "regression.lua") error = - assert_raise Lua.RuntimeException, fn -> + assert_raise RuntimeException, fn -> Lua.call_function!(lua, [:foo], []) end diff --git a/test/lua/runtime_exception_test.exs b/test/lua/runtime_exception_test.exs index bb739f39..0a48fdf3 100644 --- a/test/lua/runtime_exception_test.exs +++ b/test/lua/runtime_exception_test.exs @@ -319,6 +319,87 @@ defmodule Lua.RuntimeExceptionTest do end end + describe "kind/value projection when wrapping a VM exception" do + test "wrapping a RuntimeError yields kind :error and the raised value" do + inner = Lua.VM.RuntimeError.exception(value: "boom", source: "t.lua", line: 3) + + exception = RuntimeException.exception(inner) + + assert exception.kind == :error + assert exception.value == "boom" + assert exception.original == inner + # Structured context is copied onto the wrapper for pattern-matching. + assert exception.line == 3 + assert exception.source == "t.lua" + end + + test "wrapping a RuntimeError projects the §6.1 lua_value when present" do + inner = Lua.VM.RuntimeError.exception(value: "boom", lua_value: "t.lua:3: boom") + + exception = RuntimeException.exception(inner) + + # pcall parity: string errors carry the position-prefixed view. + assert exception.value == "t.lua:3: boom" + end + + test "wrapping a RuntimeError preserves a non-string Lua value verbatim" do + inner = Lua.VM.RuntimeError.exception(value: 42) + + exception = RuntimeException.exception(inner) + + assert exception.kind == :error + assert exception.value == 42 + end + + test "wrapping a TypeError yields kind :type" do + inner = Lua.VM.TypeError.exception(value: "attempt to index a nil value") + + exception = RuntimeException.exception(inner) + + assert exception.kind == :type + assert exception.value == "attempt to index a nil value" + end + + test "wrapping an ArgumentError yields kind :argument and the raw bad-argument string" do + inner = + Lua.VM.ArgumentError.exception( + function_name: "string.rep", + arg_num: 2, + expected: "number" + ) + + exception = RuntimeException.exception(inner) + + assert exception.kind == :argument + assert exception.value == "bad argument #2 to 'string.rep' (number expected)" + end + + test "wrapping an AssertionError yields kind :assertion" do + inner = Lua.VM.AssertionError.exception(value: "nope") + + exception = RuntimeException.exception(inner) + + assert exception.kind == :assertion + assert exception.value == "nope" + end + + test "wrapping an InternalError yields kind :internal" do + inner = Lua.VM.InternalError.exception(value: "invariant violated") + + exception = RuntimeException.exception(inner) + + assert exception.kind == :internal + assert exception.value == "invariant violated" + end + + test "wrapping an arbitrary Elixir exception leaves kind and value nil" do + exception = RuntimeException.exception(KeyError.exception(key: :missing, term: %{})) + + assert exception.kind == nil + assert exception.value == nil + end + end + describe "format_function/2 (private function tested via keyword list exception)" do test "formats function with empty scope" do exception = diff --git a/test/lua/vm/pcall_state_preservation_test.exs b/test/lua/vm/pcall_state_preservation_test.exs index 8649c1c6..c174e7f0 100644 --- a/test/lua/vm/pcall_state_preservation_test.exs +++ b/test/lua/vm/pcall_state_preservation_test.exs @@ -521,7 +521,9 @@ defmodule Lua.VM.PcallStatePreservationTest do end """) - assert {:error, %Lua.VM.RuntimeError{} = error, lua} = Lua.call_function(lua, [:f], []) + assert {:error, %Lua.RuntimeException{kind: :error} = error, lua} = + Lua.call_function(lua, [:f], []) + assert Exception.message(error) =~ "boom" assert Lua.get!(lua, [:x]) == 2 end From 33fba1a200abc5b9592cd71bc6accda4e71d16b2 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Wed, 15 Jul 2026 09:11:05 -0700 Subject: [PATCH 2/2] docs: drop internal VM exception structs from the published surface Marking the VM error structs @moduledoc false hid them from ExDoc, so the CHANGELOG's backtick references to them tripped "references a hidden module" under `mix docs --warnings-as-errors`. Align the docs config with the new stance: the VM structs are internal, so remove them from @public_modules and the "Errors" group (leaving only Lua.RuntimeException and Lua.CompilerException, plus the public Lua.Parser.Error), drop their now-unused aliases, and add them to skip_code_autolink_to so prose references render as plain code. --- mix.exs | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/mix.exs b/mix.exs index 33cc32a7..3a14706f 100644 --- a/mix.exs +++ b/mix.exs @@ -2,9 +2,6 @@ defmodule Lua.MixProject do use Mix.Project alias Lua.Parser.Error - alias Lua.VM.ArgumentError - alias Lua.VM.RuntimeError - alias Lua.VM.TypeError alias Mix.Tasks.Lua.Eval @url "https://github.com/tv-labs/lua" @@ -20,9 +17,6 @@ defmodule Lua.MixProject do Lua.Chunk, Lua.RuntimeException, Lua.CompilerException, - RuntimeError, - TypeError, - ArgumentError, Error, Eval ] @@ -59,22 +53,25 @@ defmodule Lua.MixProject do source_ref: "v#{@version}", # Render only the curated public surface; keep internals in source/IEx. filter_modules: fn module, _meta -> module in @public_modules end, - # The public VM exception structs' moduledocs name internal plumbing - # (`Lua.VM.Executor`, `Lua.VM.ErrorFormatter`) that stays filtered. - # Render those references as plain code instead of autolinking to - # filtered pages, which errors under `--warnings-as-errors`. + # Docs (CHANGELOG, moduledocs) name internal plumbing that stays + # filtered — the VM exception structs behind `Lua.RuntimeException` and + # their helpers. Render those references as plain code instead of + # autolinking to filtered pages, which errors under + # `--warnings-as-errors`. skip_code_autolink_to: [ "Lua.VM.Executor.current_position/0", - "Lua.VM.ErrorFormatter.to_map/3" + "Lua.VM.ErrorFormatter.to_map/3", + "Lua.VM.RuntimeError", + "Lua.VM.TypeError", + "Lua.VM.ArgumentError", + "Lua.VM.AssertionError", + "Lua.VM.InternalError" ], groups_for_modules: [ Core: [Lua, Lua.API, Lua.Table, Lua.Chunk], Errors: [ Lua.RuntimeException, Lua.CompilerException, - RuntimeError, - TypeError, - ArgumentError, Error ], "Mix Tasks": [Eval]