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
13 changes: 7 additions & 6 deletions guides/migrating-to-1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,17 @@ exception's `:value` field, matching what `pcall` hands back inside Lua.
instead of `{:error, [String.t()]}`; its `:errors` field carries the bare,
ANSI-free messages for programmatic inspection.

Render messages through `Exception.message/1`, **not** the `:message`
struct field — messages render lazily, so `:message` may be `nil` on the
struct:
Render messages through `Exception.message/1`. There is no `:message`
struct field to read — the message is composed lazily from the exception's
semantic fields (`:kind`, `:value`, `:original`, `:line`, `:source`) at
render time:

```elixir
# Prefer this:
# The only way to render:
Exception.message(exception)

# Not this — the field may be nil:
exception.message
# There is no exception.message field — inspect :kind / :value / :original
# for programmatic access instead.
```

## Parser error messages have a new format
Expand Down
75 changes: 36 additions & 39 deletions lib/lua/runtime_exception.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@ 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.

Use `Exception.message/1` to render the message — do not read the `:message`
field directly. When this wraps a VM exception the field is `nil` and the
message is rendered lazily so ANSI color is gated on `IO.ANSI.enabled?/0` at
output time rather than frozen at construction (issue #384).
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.

Fields:

* `: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
Expand All @@ -33,34 +32,26 @@ defmodule Lua.RuntimeException do

@type t :: %__MODULE__{}

defexception [:message, :original, :kind, :value, :state, :line, :source, :call_stack]
defexception [:original, :kind, :value, :state, :line, :source, :call_stack]

@impl true
def exception({:lua_error, error, _state}) do
%__MODULE__{
original: error,
message: prefix_message(Util.format_error(error))
}
end

def exception({:api_error, details, state}) do
%__MODULE__{original: details, state: state, message: "Lua API error: #{details}"}
%__MODULE__{original: error}
end

def exception(list) when is_list(list) do
scope = Keyword.fetch!(list, :scope)
function = Keyword.fetch!(list, :function)
message = Keyword.fetch!(list, :message)

%__MODULE__{
original: list,
state: nil,
message: prefix_message("#{format_function(scope, function)} failed, #{message}")
}
# Validate the descriptor eagerly so a missing key fails at the raise site
# rather than lazily inside `message/1`. The list itself is the semantic
# source — `message/1` reads `:scope`/`:function`/`:message` back off it.
Keyword.fetch!(list, :scope)
Keyword.fetch!(list, :function)
Keyword.fetch!(list, :message)

%__MODULE__{original: list}
end

def exception(error) when is_binary(error) do
%__MODULE__{message: prefix_message(String.trim(error))}
%__MODULE__{original: String.trim(error)}
end

def exception(error) do
Expand All @@ -72,8 +63,8 @@ defmodule Lua.RuntimeException do
{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
# No message is stored — `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__{
Expand All @@ -86,21 +77,27 @@ defmodule Lua.RuntimeException do
}
end

# The `:lua_error`, `:api_error`, keyword-list, and binary clauses build
# ANSI-free strings that don't depend on the terminal, so they memoize into
# `:message`. The generic clause defers to keep the ANSI gate honest.
# Everything renders lazily from `:original`, so the ANSI gate on wrapped VM
# exceptions is evaluated when the message is written, not frozen here.
@impl true
def message(%__MODULE__{message: message}) when is_binary(message), do: message
def message(%__MODULE__{original: original}) when is_exception(original) do
prefix_message(Exception.message(original))
end

def message(%__MODULE__{original: list}) when is_list(list) do
prefix_message("#{format_function(list[:scope], list[:function])} failed, #{list[:message]}")
end

def message(%__MODULE__{original: binary}) when is_binary(binary) do
prefix_message(String.trim(binary))
end

def message(%__MODULE__{original: {tag, _, _} = term}) when tag in [:badarith, :illegal_index] do
prefix_message(Util.format_error(term))
end

def message(%__MODULE__{original: original}) do
rendered =
if is_exception(original) do
Exception.message(original)
else
inspect(original)
end

prefix_message(rendered)
prefix_message(inspect(original))
end

defp prefix_message(@runtime_prefix <> _ = msg), do: msg
Expand Down
10 changes: 5 additions & 5 deletions lib/lua/vm/internal_error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ defmodule Lua.VM.InternalError do
# Raised for internal VM errors: bad native function returns, unimplemented
# instructions, and other invariant violations.

defexception [:value, :message]
defexception [:value]

@impl true
def exception(opts) do
value = Keyword.get(opts, :value)
message = Keyword.get(opts, :message) || stringify(value)

%__MODULE__{value: value, message: message}
%__MODULE__{value: Keyword.get(opts, :value)}
end

@impl true
def message(%__MODULE__{value: value}), do: stringify(value)

defp stringify(nil), do: "nil"
defp stringify(v) when is_binary(v), do: v
defp stringify(v), do: inspect(v)
Expand Down
2 changes: 1 addition & 1 deletion tasks/lua.suite.ex
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ defmodule Mix.Tasks.Lua.Suite do
{:timeout, file, timeout}

{:exit, reason} ->
{:fail, file, %Lua.RuntimeException{message: "exited: #{inspect(reason)}"}}
{:fail, file, Lua.RuntimeException.exception("exited: #{inspect(reason)}")}
end
end

Expand Down
48 changes: 18 additions & 30 deletions test/lua/runtime_exception_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -78,32 +78,6 @@ defmodule Lua.RuntimeExceptionTest do
end
end

describe "exception/1 with {:api_error, details, state}" do
test "creates exception with api error message" do
state = State.new()
details = "invalid function call"

exception = RuntimeException.exception({:api_error, details, state})

assert Exception.message(exception) == "Lua API error: invalid function call"
assert exception.original == details
assert exception.state == state
end

test "handles complex api error details" do
state = State.new()
details = "function returned invalid type: expected table, got nil"

exception = RuntimeException.exception({:api_error, details, state})

assert Exception.message(exception) ==
"Lua API error: function returned invalid type: expected table, got nil"

assert exception.original == details
assert exception.state == state
end
end

describe "exception/1 with keyword list [scope:, function:, message:]" do
test "formats error with empty scope" do
exception =
Expand Down Expand Up @@ -197,23 +171,24 @@ defmodule Lua.RuntimeExceptionTest do
exception = RuntimeException.exception("something went wrong")

assert Exception.message(exception) == "Lua runtime error: something went wrong"
assert exception.original == nil
# The trimmed host string is the semantic source, stored on `:original`.
assert exception.original == "something went wrong"
assert exception.state == nil
end

test "trims whitespace from binary error" do
exception = RuntimeException.exception(" error with spaces \n")

assert Exception.message(exception) == "Lua runtime error: error with spaces"
assert exception.original == nil
assert exception.original == "error with spaces"
assert exception.state == nil
end

test "handles empty binary string" do
exception = RuntimeException.exception("")

assert Exception.message(exception) == "Lua runtime error: "
assert exception.original == nil
assert exception.original == ""
assert exception.state == nil
end

Expand All @@ -226,7 +201,7 @@ defmodule Lua.RuntimeExceptionTest do
exception = RuntimeException.exception(error_message)

assert Exception.message(exception) == "Lua runtime error: multi-line error\nwith details"
assert exception.original == nil
assert exception.original == "multi-line error\nwith details"
assert exception.state == nil
end
end
Expand Down Expand Up @@ -386,10 +361,15 @@ defmodule Lua.RuntimeExceptionTest do
test "wrapping an InternalError yields kind :internal" do
inner = Lua.VM.InternalError.exception(value: "invariant violated")

# InternalError renders via message/1 from :value (no stored :message).
refute Map.has_key?(inner, :message)
assert Exception.message(inner) == "invariant violated"

exception = RuntimeException.exception(inner)

assert exception.kind == :internal
assert exception.value == "invariant violated"
assert Exception.message(exception) == "Lua runtime error: invariant violated"
end

test "wrapping an arbitrary Elixir exception leaves kind and value nil" do
Expand Down Expand Up @@ -442,6 +422,14 @@ defmodule Lua.RuntimeExceptionTest do
assert Exception.message(exception) == "Lua runtime error: test error"
end

test "struct has no :message field — Exception.message/1 is the only renderer" do
# The message is composed lazily from the semantic fields; there is no
# `:message` field to read back nil (issue #384 / 1.0 cleanup).
exception = RuntimeException.exception("test error")

refute Map.has_key?(exception, :message)
end

test "can be raised with raise/2" do
assert_raise RuntimeException, "Lua runtime error: test", fn ->
raise RuntimeException, "test"
Expand Down
Loading