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
27 changes: 19 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ The first stable release on the Elixir-native Lua 5.3 VM, culminating the
`0.4.0`? See [Migrating to 1.0](guides/migrating-to-1.0.md) for the full
walkthrough. The changes below are those since `rc.3`.

### Added
- `Lua.format_exception/1` renders a `Lua.RuntimeException` or
`Lua.CompilerException` as the rich, human-readable report — location, source
context, stack trace, and suggestions — with ANSI color when
`IO.ANSI.enabled?/0` is true. This is the report `mix lua.eval` prints (#393).
- `Lua.RuntimeException.to_map/2` and `Lua.CompilerException.to_map/1` expose a
wire-safe structured representation (no ANSI) for JSON payloads, structured
logs, and UI-facing error reporting (#393).

### Changed
- Elixir callbacks now receive the public `Lua.t` (`%Lua{}`) as their state
argument **regardless of how they enter the VM**. Previously a two-arity
Expand Down Expand Up @@ -108,11 +117,12 @@ walkthrough. The changes below are those since `rc.3`.
`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
(#384).
field. `Exception.message/1` returns a plain, single-line, ANSI-free string
(safe for `Logger` and error trackers); the rich, ANSI-gated report —
location, source context, stack trace, and suggestions — is available via
`Lua.format_exception/1`. Likewise `Lua.CompilerException`'s `:errors` field
now holds bare, ANSI-free messages rather than the fully formatted (and
previously ANSI-colored) diagnostics (#384, #393).

### Changed
- `Lua.new/1`'s `:max_string_bytes` now accepts `:infinity` for no limit,
Expand All @@ -126,9 +136,10 @@ walkthrough. The changes below are those since `rc.3`.
rendering) into non-terminal sinks such as log files, container stdout, or a
Sentry/AppSignal title. Messages were formatted eagerly during VM execution —
where `IO.ANSI.enabled?/0` is true — freezing escape codes into the struct
that survived long after the TTY gate. They now render at `Exception.message/1`
call time, so the ANSI gate is evaluated where the message is actually written
(#384).
that survived long after the TTY gate. `Exception.message/1` now returns a
plain, ANSI-free, single-line message; the rich, ANSI-gated report lives in
`Lua.format_exception/1`, where the gate is evaluated at call time — where the
report is actually written (#384, #393).
- `Lua.eval!/3` now accepts a `:source` option when evaluating a pre-compiled
`Lua.Chunk`, matching the string-script clause. Previously
`eval!(lua, chunk, source: "x")` raised via `Keyword.validate!`. For a chunk
Expand Down
29 changes: 25 additions & 4 deletions lib/lua.ex
Original file line number Diff line number Diff line change
Expand Up @@ -710,10 +710,12 @@ defmodule Lua do
When the function raises, `call_function/3` returns `{:error, exception,
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).
failure, and `:original` to reach the internal VM exception. Render it
yourself: `Exception.message/1` for a plain, single-line, log-safe string;
`Lua.format_exception/1` for the rich report (location, stack trace,
suggestions, ANSI on a TTY); or `Lua.RuntimeException.to_map/2` for structured
data. The boundary does *not* pre-render — `:original` holds the raw VM
exception.

iex> {[ref], lua} = Lua.eval!(Lua.new(), "return function() error('boom') end", decode: false)
iex> {:error, exception, _lua} = Lua.call_function(lua, ref, [])
Expand Down Expand Up @@ -931,6 +933,25 @@ defmodule Lua do
@spec unwrap(term()) :: term()
defdelegate unwrap(value), to: Display

@doc """
Renders a `Lua.RuntimeException` or `Lua.CompilerException` as a rich,
human-readable report — location, source context, stack trace, and
suggestions — with ANSI color when `IO.ANSI.enabled?/0` is true.

This is the terminal/REPL rendering used by `mix lua.eval`. For a plain,
single-line message suitable for `Logger` and error trackers use
`Exception.message/1`; for structured data (JSON, a UI) use the exception's
`to_map/2`.

iex> exception = Lua.RuntimeException.exception("boom")
iex> Lua.format_exception(exception)
"Lua runtime error: boom"
"""
@spec format_exception(Lua.RuntimeException.t() | Lua.CompilerException.t()) :: String.t()
def format_exception(%Lua.RuntimeException{} = exception), do: Lua.RuntimeException.format(exception)

def format_exception(%Lua.CompilerException{} = exception), do: Lua.CompilerException.format(exception)

@doc """
Encodes a Lua value into its internal form

Expand Down
55 changes: 40 additions & 15 deletions lib/lua/compiler_exception.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ defmodule Lua.CompilerException do
@moduledoc """
Raised when Lua source cannot be lexed, parsed, or compiled.

Use `Exception.message/1` to render the full, human-readable report (location,
source context, pointer, and suggestions). ANSI color is applied only when
`IO.ANSI.enabled?/0` is true *at render time*, so the same exception logs
cleanly to a file and renders in color on a TTY (issue #384).
`Exception.message/1` returns a plain, ANSI-free string — the bare per-error
messages under a `Failed to compile Lua!` header — safe to log. For the full,
human-readable report (location, source context, pointer, suggestions, and
ANSI color on a TTY) use `Lua.format_exception/1`, which is what
`mix lua.eval` prints. For structured data use `to_map/1`.

The `:errors` field carries the bare, ANSI-free error messages (no location
header or source context) for programmatic inspection and clean logging.
Expand Down Expand Up @@ -51,30 +52,54 @@ defmodule Lua.CompilerException do
%__MODULE__{errors: [error]}
end

# Compile-time errors don't have a meaningful runtime stack trace — they're
# produced by the lexer/parser/compiler before any code is executed. The rich
# source context (line, column, pointer to the offending token) is rendered
# here from the structured diagnostics, so we join it under a clear
# "Failed to compile" header.
# Plain, ANSI-free rendering from the bare `:errors` strings — no location
# header, source context, or color. Safe to log. The rich source-context
# report lives in `format/1`.
@impl true
def message(%__MODULE__{diagnostics: [_ | _] = diagnostics, source: source}) do
rendered = Enum.map_join(diagnostics, "\n", &Error.format(&1, source))

def message(%__MODULE__{errors: errors}) do
"""
Failed to compile Lua!

#{rendered}
#{Enum.join(errors, "\n")}
"""
end

def message(%__MODULE__{errors: errors}) do
@doc """
Renders the rich, human-readable report — location, source context, a pointer
to the offending token, and suggestions — with ANSI color when
`IO.ANSI.enabled?/0` is true. Used by `Lua.format_exception/1`.

Compile-time errors have no runtime stack trace; the context is rendered from
the structured `:diagnostics`. Falls back to the plain `message/1` when no
structured diagnostics are present (lexer/compiler errors).
"""
@spec format(t()) :: String.t()
def format(%__MODULE__{diagnostics: [_ | _] = diagnostics, source: source}) do
rendered = Enum.map_join(diagnostics, "\n", &Error.format(&1, source))

"""
Failed to compile Lua!

#{Enum.join(errors, "\n")}
#{rendered}
"""
end

def format(%__MODULE__{} = e), do: message(e)

@doc """
Returns a wire-safe structured representation — a list of per-error maps in
the `Lua.Parser.Error` shape (`type`, `message`, `line`, `source_context`,
`suggestion`, …), with no ANSI escapes. Returns `[]` for non-parser inputs
(lexer/compiler errors) that carry no structured diagnostics; read `:errors`
for those.
"""
@spec to_map(t()) :: [map()]
def to_map(%__MODULE__{diagnostics: [_ | _] = diagnostics, source: source}) do
Enum.map(diagnostics, &Error.to_map(&1, source))
end

def to_map(%__MODULE__{}), do: []

defp clean_message(%Error{message: message}) when is_binary(message), do: String.trim(message)
defp clean_message(%Error{}), do: ""
end
105 changes: 91 additions & 14 deletions lib/lua/runtime_exception.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ defmodule Lua.RuntimeException do
arithmetic on a non-number, indexing a nil, an explicit `error()`
call from Lua, or any other dynamic failure inside the VM.

Always render with `Exception.message/1` — there is no `:message` struct
field. The message is composed lazily from `:original` (and the semantic
fields below) so ANSI color is gated on `IO.ANSI.enabled?/0` at output time
rather than frozen at construction (issue #384), and so there is no field
that reads back `nil` for the common case of a wrapped VM error.
`Exception.message/1` returns a plain, single-line, ANSI-free string — the
error body plus a compact ` (at source:line)` suffix — safe to drop into a
`Logger` call or an error tracker. There is no `:message` struct field; the
message is composed lazily from `:original` (and the semantic fields below),
so there is no field that reads back `nil` for the common case of a wrapped
VM error.

For a rich, human-readable report (location header, stack trace, suggestions,
and ANSI color on a TTY) use `Lua.format_exception/1` — this is what
`mix lua.eval` prints. For structured error reporting (JSON, a UI) use
`to_map/2`.

Fields:

Expand All @@ -24,7 +30,12 @@ defmodule Lua.RuntimeException do
* `:call_stack` — list of Lua frames at failure
"""
alias Lua.Util
alias Lua.VM.ArgumentError
alias Lua.VM.AssertionError
alias Lua.VM.InternalError
alias Lua.VM.ProtectedCall
alias Lua.VM.RuntimeError
alias Lua.VM.TypeError

@runtime_prefix "Lua runtime error: "

Expand Down Expand Up @@ -77,11 +88,13 @@ defmodule Lua.RuntimeException do
}
end

# Everything renders lazily from `:original`, so the ANSI gate on wrapped VM
# exceptions is evaluated when the message is written, not frozen here.
# Plain, single-line, ANSI-free rendering. The wrapped VM exception's
# `message/1` returns just the error body; we prefix it and append the Lua
# source location so a log line carries the "where" without a multi-line
# block. The rich render lives in `format/1`.
@impl true
def message(%__MODULE__{original: original}) when is_exception(original) do
prefix_message(Exception.message(original))
def message(%__MODULE__{original: original} = e) when is_exception(original) do
prefix_message(Exception.message(original)) <> location_suffix(e)
end

def message(%__MODULE__{original: list}) when is_list(list) do
Expand All @@ -100,6 +113,70 @@ defmodule Lua.RuntimeException do
prefix_message(inspect(original))
end

@doc """
Renders the rich, multi-line report for this error — location header, stack
trace, and suggestions — with ANSI color when `IO.ANSI.enabled?/0` is true.

This is the terminal/REPL rendering `mix lua.eval` prints and what
`Lua.format_exception/1` delegates to. For the plain, single-line, log-safe
string use `Exception.message/1`; for structured data use `to_map/2`.
"""
@spec format(t()) :: String.t()
def format(%__MODULE__{original: original}) when is_exception(original) do
prefix_message(rich(original))
end

def format(%__MODULE__{} = e), do: message(e)

@doc """
Returns a wire-safe structured map — `message`, `source`, `line`,
`call_stack`, `source_context`, `suggestion`, `error_kind` — with no ANSI
escapes in any field. Intended for JSON payloads, structured logs, and
UI-facing error reporting.

Pass `:source_code` to populate `source_context`. Wrapped VM errors delegate
to their own `to_map/2`; host-side errors (no Lua value) return a minimal map.
"""
@spec to_map(t(), keyword()) :: map()
def to_map(exception, opts \\ [])

def to_map(%__MODULE__{original: %RuntimeError{} = o}, opts), do: RuntimeError.to_map(o, opts)

def to_map(%__MODULE__{original: %TypeError{} = o}, opts), do: TypeError.to_map(o, opts)

def to_map(%__MODULE__{original: %AssertionError{} = o}, opts), do: AssertionError.to_map(o, opts)

def to_map(%__MODULE__{original: %ArgumentError{} = o}, opts), do: ArgumentError.to_map(o, opts)

def to_map(%__MODULE__{} = e, _opts), do: minimal_map(e)

defp rich(%RuntimeError{} = o), do: RuntimeError.format(o)
defp rich(%TypeError{} = o), do: TypeError.format(o)
defp rich(%AssertionError{} = o), do: AssertionError.format(o)
defp rich(%ArgumentError{} = o), do: ArgumentError.format(o)
defp rich(%InternalError{} = o), do: InternalError.format(o)
defp rich(other), do: Exception.message(other)

defp minimal_map(%__MODULE__{} = e) do
%{
type: nil,
message: message(e),
source: e.source,
line: e.line,
call_stack: e.call_stack || [],
source_context: nil,
suggestion: nil,
error_kind: nil
}
end

defp location_suffix(%__MODULE__{source: source, line: line}) when not is_nil(source) and not is_nil(line),
do: " (at #{source}:#{line})"

defp location_suffix(%__MODULE__{source: source}) when not is_nil(source), do: " (at #{source})"
defp location_suffix(%__MODULE__{line: line}) when not is_nil(line), do: " (at line #{line})"
defp location_suffix(_), do: ""

defp prefix_message(@runtime_prefix <> _ = msg), do: msg
defp prefix_message(msg), do: @runtime_prefix <> msg

Expand All @@ -111,11 +188,11 @@ defmodule Lua.RuntimeException do

# 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(%RuntimeError{}), do: :error
defp kind(%TypeError{}), do: :type
defp kind(%ArgumentError{}), do: :argument
defp kind(%AssertionError{}), do: :assertion
defp kind(%InternalError{}), do: :internal
defp kind(_), do: nil

defp format_function([], function), do: "#{function}()"
Expand Down
32 changes: 30 additions & 2 deletions lib/lua/vm/argument_error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ defmodule Lua.VM.ArgumentError do
# 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.ErrorFormatter
alias Lua.VM.RuntimeError

@type t :: %__MODULE__{}
Expand Down Expand Up @@ -66,15 +67,42 @@ defmodule Lua.VM.ArgumentError do
}
end

# Plain, single-line, ANSI-free body — safe to log and consumed by the public
# `Lua.RuntimeException` wrapper. The rich multi-line render lives in
# `format/1`.
@impl true
def message(%__MODULE__{} = e) do
Lua.VM.ErrorFormatter.format(:type_error, raw_message(e),
def message(%__MODULE__{} = e), do: raw_message(e)

@doc """
Rich multi-line render — location header and stack trace, ANSI-colored when
`IO.ANSI.enabled?/0` is true at call time (evaluated lazily, never frozen at
construction; see issue #384). Used by `Lua.format_exception/1`.
"""
@spec format(t()) :: String.t()
def format(%__MODULE__{} = e) do
ErrorFormatter.format(:type_error, raw_message(e),
source: e.source,
line: e.line,
call_stack: e.call_stack
)
end

@doc """
Returns a wire-safe structured map for this error. See
`Lua.VM.ErrorFormatter.to_map/3` for the shape.

Pass `:source_code` to populate `source_context`.
"""
@spec to_map(t(), keyword()) :: map()
def to_map(%__MODULE__{} = e, opts \\ []) do
ErrorFormatter.to_map(:argument_error, raw_message(e),
source: e.source,
line: e.line,
call_stack: e.call_stack,
source_code: Keyword.get(opts, :source_code)
)
end

@doc """
Returns the unformatted Lua-facing error value — the bare
`"bad argument #N to 'F' (expected, got got)"` string with no location
Expand Down
Loading
Loading