Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 26 additions & 14 deletions lib/lua.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 <source>:<line>:` header, no `Suggestion:` block).
Expand All @@ -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, [])
Expand All @@ -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.
Expand Down
28 changes: 25 additions & 3 deletions lib/lua/runtime_exception.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<eval>`)
* `: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
Expand Down Expand Up @@ -57,16 +65,21 @@ 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
# (issue #384); freezing their rendered message here would bake in escape
# 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
Expand Down Expand Up @@ -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
Expand Down
67 changes: 26 additions & 41 deletions lib/lua/vm/argument_error.ex
Original file line number Diff line number Diff line change
@@ -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

Expand Down
15 changes: 9 additions & 6 deletions lib/lua/vm/assertion_error.ex
Original file line number Diff line number Diff line change
@@ -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

Expand Down
12 changes: 8 additions & 4 deletions lib/lua/vm/internal_error.ex
Original file line number Diff line number Diff line change
@@ -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]

Expand Down
25 changes: 13 additions & 12 deletions lib/lua/vm/runtime_error.ex
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 13 additions & 11 deletions lib/lua/vm/type_error.ex
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Loading
Loading