diff --git a/CHANGELOG.md b/CHANGELOG.md index 968d0d99..23ca4f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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, @@ -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 diff --git a/lib/lua.ex b/lib/lua.ex index 0c72526b..0b47bcdb 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -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 ::` 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, []) @@ -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 diff --git a/lib/lua/compiler_exception.ex b/lib/lua/compiler_exception.ex index 52e7d2a5..64f01232 100644 --- a/lib/lua/compiler_exception.ex +++ b/lib/lua/compiler_exception.ex @@ -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. @@ -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 diff --git a/lib/lua/runtime_exception.ex b/lib/lua/runtime_exception.ex index 50e8f7c8..0be862d9 100644 --- a/lib/lua/runtime_exception.ex +++ b/lib/lua/runtime_exception.ex @@ -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: @@ -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: " @@ -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 @@ -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 @@ -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}()" diff --git a/lib/lua/vm/argument_error.ex b/lib/lua/vm/argument_error.ex index 12efb0b1..c45a161c 100644 --- a/lib/lua/vm/argument_error.ex +++ b/lib/lua/vm/argument_error.ex @@ -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__{} @@ -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 diff --git a/lib/lua/vm/assertion_error.ex b/lib/lua/vm/assertion_error.ex index a89f534b..d8372fc0 100644 --- a/lib/lua/vm/assertion_error.ex +++ b/lib/lua/vm/assertion_error.ex @@ -34,12 +34,24 @@ defmodule Lua.VM.AssertionError do } end - # Rendered lazily so `IO.ANSI.enabled?/0` is evaluated when the message is - # actually written rather than frozen at construction time. See issue #384; - # mirrors `Lua.VM.ArgumentError.message/1`. + # 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__{} = error) do - format_message(error.value, error.source, error.line, error.call_stack) + def message(%__MODULE__{value: value}), do: raw_message(value) + + @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__{} = error) do + ErrorFormatter.format(:assertion_error, raw_message(error.value), + source: error.source, + line: error.line, + call_stack: error.call_stack + ) end @doc """ @@ -58,14 +70,6 @@ defmodule Lua.VM.AssertionError do ) end - defp format_message(value, source, line, call_stack) do - ErrorFormatter.format(:assertion_error, raw_message(value), - source: source, - line: line, - call_stack: call_stack - ) - end - defp raw_message(value), do: "assertion failed: #{stringify(value)}" defp stringify(nil), do: "nil" diff --git a/lib/lua/vm/internal_error.ex b/lib/lua/vm/internal_error.ex index 4124c098..1658e951 100644 --- a/lib/lua/vm/internal_error.ex +++ b/lib/lua/vm/internal_error.ex @@ -18,6 +18,12 @@ defmodule Lua.VM.InternalError do @impl true def message(%__MODULE__{value: value}), do: stringify(value) + # No location/stack machinery for internal errors, so the rich render is the + # same plain string. Present so `Lua.RuntimeException.format/1` can dispatch + # uniformly across wrapped VM errors. + @doc false + def format(%__MODULE__{} = e), do: message(e) + defp stringify(nil), do: "nil" defp stringify(v) when is_binary(v), do: v defp stringify(v), do: inspect(v) diff --git a/lib/lua/vm/runtime_error.ex b/lib/lua/vm/runtime_error.ex index 9d4edda8..1ef052c8 100644 --- a/lib/lua/vm/runtime_error.ex +++ b/lib/lua/vm/runtime_error.ex @@ -47,13 +47,24 @@ defmodule Lua.VM.RuntimeError do } end - # Rendered lazily so `IO.ANSI.enabled?/0` is evaluated when the message is - # actually written (log sink, TTY, Sentry) rather than frozen at construction - # time — where it would run inside a Lua execution and always see a TTY. See - # issue #384; mirrors `Lua.VM.ArgumentError.message/1`. + # Plain, single-line, ANSI-free body — safe to log and consumed by the public + # `Lua.RuntimeException` wrapper. The rich multi-line render (location, stack + # trace, ANSI on a TTY) lives in `format/1`. @impl true - def message(%__MODULE__{} = error) do - format_message(error.value, error.source, error.line, error.call_stack) + def message(%__MODULE__{value: value}), do: stringify(value) + + @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__{} = error) do + ErrorFormatter.format(:runtime_error, raw_message(error.value), + source: error.source, + line: error.line, + call_stack: error.call_stack + ) end @doc """ @@ -72,14 +83,6 @@ defmodule Lua.VM.RuntimeError do ) end - defp format_message(value, source, line, call_stack) do - ErrorFormatter.format(:runtime_error, raw_message(value), - source: source, - line: line, - call_stack: call_stack - ) - end - defp raw_message(value), do: "runtime error: #{stringify(value)}" # PUC-Lua renders string and number error objects verbatim, but any other diff --git a/lib/lua/vm/type_error.ex b/lib/lua/vm/type_error.ex index 18774e6f..b775390c 100644 --- a/lib/lua/vm/type_error.ex +++ b/lib/lua/vm/type_error.ex @@ -40,18 +40,26 @@ defmodule Lua.VM.TypeError do } end - # Rendered lazily so `IO.ANSI.enabled?/0` is evaluated when the message is - # actually written rather than frozen at construction time. See issue #384; - # mirrors `Lua.VM.ArgumentError.message/1`. + # 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__{} = error) do - format_message( - error.value, - error.source, - error.line, - error.call_stack, - error.error_kind, - error.value_type + def message(%__MODULE__{value: value}), do: raw_message(value) + + @doc """ + Rich multi-line render — location header, stack trace, and a suggestion keyed + off `:error_kind`/`:value_type`; 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__{} = error) do + ErrorFormatter.format(:type_error, raw_message(error.value), + source: error.source, + line: error.line, + call_stack: error.call_stack, + error_kind: error.error_kind, + value_type: error.value_type ) end @@ -73,16 +81,6 @@ defmodule Lua.VM.TypeError do ) end - defp format_message(value, source, line, call_stack, error_kind, value_type) do - ErrorFormatter.format(:type_error, raw_message(value), - source: source, - line: line, - call_stack: call_stack, - error_kind: error_kind, - value_type: value_type - ) - end - defp raw_message(value), do: stringify(value) defp stringify(nil), do: "nil" diff --git a/lib/mix/tasks/lua.eval.ex b/lib/mix/tasks/lua.eval.ex index cf3395c4..c4d6c23c 100644 --- a/lib/mix/tasks/lua.eval.ex +++ b/lib/mix/tasks/lua.eval.ex @@ -50,7 +50,7 @@ defmodule Mix.Tasks.Lua.Eval do IO.puts(inspect(results, pretty: true, limit: :infinity, charlists: :as_lists)) rescue e in [Lua.CompilerException, Lua.RuntimeException] -> - IO.puts(:stderr, Exception.message(e)) + IO.puts(:stderr, Lua.format_exception(e)) exit({:shutdown, 1}) end end diff --git a/test/lua/call_function_error_value_test.exs b/test/lua/call_function_error_value_test.exs index 4a22ac5b..2a8271a8 100644 --- a/test/lua/call_function_error_value_test.exs +++ b/test/lua/call_function_error_value_test.exs @@ -10,9 +10,11 @@ defmodule Lua.CallFunctionErrorValueTest do * `: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. + The boundary does no rendering: callers own that. `Exception.message/1` + gives the plain, single-line, log-safe form; `Lua.format_exception/1` gives + the rich `ErrorFormatter` render. The raising variant `call_function!/3` + re-raises the same `Lua.RuntimeException`, so both renderings are available + from the raised exception. """ use ExUnit.Case, async: true @@ -109,7 +111,7 @@ defmodule Lua.CallFunctionErrorValueTest do end describe "call_function!/3 on an unresolved name keeps the rich render" do - test "raises with the location-less rich render and a suggestion" do + test "Lua.format_exception/1 renders the location-less rich report and a suggestion" do {_, lua} = Lua.eval!(Lua.new(), "function foo() return 1 end") error = @@ -117,7 +119,7 @@ defmodule Lua.CallFunctionErrorValueTest do Lua.call_function!(lua, [:bar], []) end - message = Exception.message(error) + message = Lua.format_exception(error) assert message =~ "attempt to call a nil value (global 'bar')" assert message =~ "Suggestion:" # No Lua source position exists for a programmatic call, so no @@ -125,6 +127,20 @@ defmodule Lua.CallFunctionErrorValueTest do refute message =~ ~r/at \S+:\d+:/ end + test "Exception.message/1 is the plain single line — no suggestion block" do + {_, lua} = Lua.eval!(Lua.new(), "function foo() return 1 end") + + error = + assert_raise RuntimeException, fn -> + Lua.call_function!(lua, [:bar], []) + end + + message = Exception.message(error) + assert message == "Lua runtime error: attempt to call a nil value (global 'bar')" + refute message =~ "Suggestion:" + refute message =~ "\n" + end + test "the original TypeError carries the structured call-nil kind" do {_, lua} = Lua.eval!(Lua.new(), "function foo() return 1 end") @@ -170,7 +186,7 @@ defmodule Lua.CallFunctionErrorValueTest do end describe "call_function!/3 keeps the rich render" do - test "raises Lua.RuntimeException whose message carries the formatted render" do + test "Lua.format_exception/1 carries the formatted render" do {ref, lua} = fun!("function() error('boom') end") error = @@ -178,11 +194,11 @@ defmodule Lua.CallFunctionErrorValueTest do Lua.call_function!(lua, ref, []) end - message = Exception.message(error) + message = Lua.format_exception(error) assert message =~ "Lua runtime error:" assert message =~ "boom" - # The rich render includes the raw-message body that the terse - # programmatic reason deliberately omits. + # The rich render includes the raw-message body ("runtime error: boom") + # that the plain `Exception.message/1` deliberately omits. assert message =~ "runtime error:" end end @@ -242,7 +258,7 @@ defmodule Lua.CallFunctionErrorValueTest do Lua.call_function!(lua, [:foo], []) end - message = Exception.message(error) + message = Lua.format_exception(error) assert message =~ "regression.lua:2:" assert message =~ "bad argument #1 to 'pairs'" assert error.original.line == 2 @@ -266,7 +282,7 @@ defmodule Lua.CallFunctionErrorValueTest do end assert error.original.line == 2 - assert Exception.message(error) =~ "regression.lua:2:" + assert Lua.format_exception(error) =~ "regression.lua:2:" end end end diff --git a/test/lua/compiler/integration_test.exs b/test/lua/compiler/integration_test.exs index bd5449e9..e839f9f4 100644 --- a/test/lua/compiler/integration_test.exs +++ b/test/lua/compiler/integration_test.exs @@ -1379,7 +1379,7 @@ defmodule Lua.Compiler.IntegrationTest do assert {:ok, ast} = Parser.parse(code) assert {:ok, proto} = Compiler.compile(ast) - assert_raise Lua.VM.RuntimeError, ~r/runtime error: something went wrong/, fn -> + assert_raise Lua.VM.RuntimeError, ~r/something went wrong/, fn -> VM.execute(proto, state) end end diff --git a/test/lua/compiler_exception_test.exs b/test/lua/compiler_exception_test.exs index f0ed0110..42bc517f 100644 --- a/test/lua/compiler_exception_test.exs +++ b/test/lua/compiler_exception_test.exs @@ -15,17 +15,17 @@ defmodule Lua.CompilerExceptionTest do e in Lua.CompilerException -> e end - msg = Exception.message(e) + msg = Lua.format_exception(e) # The parser emits rich source context in its formatted error string, - # which `CompilerException.message/1` then forwards under the "Failed - # to compile Lua!" header. Assert the location bits survive. + # which `Lua.format_exception/1` renders under the "Failed to compile + # Lua!" header. Assert the location bits survive. assert msg =~ "Failed to compile Lua!" assert msg =~ ~r/line\s+1/ assert msg =~ ~r/column\s+\d+/ end - test "compile errors include the offending source line as context" do + test "plain Exception.message/1 is ANSI-free with no source context" do e = try do Lua.eval!(Lua.new(), "local x =;") @@ -35,6 +35,24 @@ defmodule Lua.CompilerExceptionTest do msg = Exception.message(e) + # The plain message carries the header and the bare error text only — no + # line/column, no rendered source line, no ANSI. Those live in + # `Lua.format_exception/1`. + assert msg =~ "Failed to compile Lua!" + refute msg =~ "\e[" + refute msg =~ "local x =;" + end + + test "compile errors include the offending source line as context" do + e = + try do + Lua.eval!(Lua.new(), "local x =;") + rescue + e in Lua.CompilerException -> e + end + + msg = Lua.format_exception(e) + # The parser's error formatter renders the source line itself with a # caret pointing at the failure column. assert msg =~ "local x =;" @@ -67,7 +85,7 @@ defmodule Lua.CompilerExceptionTest do e in Lua.CompilerException -> e end - msg = Exception.message(e) + msg = Lua.format_exception(e) assert msg =~ "Failed to compile Lua!" assert msg =~ ~r/line\s+1/ @@ -76,6 +94,28 @@ defmodule Lua.CompilerExceptionTest do refute msg =~ "(no position information)" end + test "to_map/1 returns structured, ANSI-free per-error maps for parse errors" do + e = + try do + Lua.eval!(Lua.new(), "local x =;") + rescue + e in Lua.CompilerException -> e + end + + assert [%{type: _, message: msg, line: 1} | _] = Lua.CompilerException.to_map(e) + assert is_binary(msg) + + for m <- Lua.CompilerException.to_map(e) do + refute m.message =~ "\e[" + end + end + + test "to_map/1 returns [] for non-parser (binary) errors" do + e = Lua.CompilerException.exception("oops") + + assert Lua.CompilerException.to_map(e) == [] + end + test "string-keyed table renders with location and a bracketed-key suggestion" do # Regression: `{ "ok" = 5 }` previously fell through the parser's # catch-all converter and rendered as raw Elixir terms with no @@ -88,7 +128,7 @@ defmodule Lua.CompilerExceptionTest do e in Lua.CompilerException -> e end - msg = Exception.message(e) + msg = Lua.format_exception(e) assert msg =~ "Failed to compile Lua!" assert msg =~ ~r/line\s+1/ diff --git a/test/lua/error_gallery_test.exs b/test/lua/error_gallery_test.exs index d60c0eff..a964f9c4 100644 --- a/test/lua/error_gallery_test.exs +++ b/test/lua/error_gallery_test.exs @@ -1,13 +1,16 @@ defmodule Lua.ErrorGalleryTest do @moduledoc """ - Locks the user-visible rendered output for every error category a Lua - program can hit. Each case evaluates a snippet and compares the public - `Exception.message/1` against an expected string defined inline next to - the source. + Locks the user-visible rich rendered output for every error category a Lua + program can hit. Each case evaluates a snippet and compares + `Lua.format_exception/1` (the report `mix lua.eval` prints) against an + expected string defined inline next to the source. The suite runs with ANSI disabled, so the expected text is stable across terminals. When the format changes on purpose, update the `expected` string for the affected case. + + The plain, single-line `Exception.message/1` form is locked separately in + `Lua.ErrorMessagesTest`. """ use ExUnit.Case, async: true @@ -154,7 +157,7 @@ defmodule Lua.ErrorGalleryTest do Lua.eval!(lua, source, source: "gallery.lua") flunk("expected #{inspect(source)} to raise") rescue - e -> Exception.message(e) + e -> Lua.format_exception(e) end end end diff --git a/test/lua/error_messages_test.exs b/test/lua/error_messages_test.exs index da074c1d..488adf8c 100644 --- a/test/lua/error_messages_test.exs +++ b/test/lua/error_messages_test.exs @@ -210,10 +210,14 @@ defmodule Lua.ErrorMessagesTest do VM.execute(proto, state) end - # Error message should be a formatted string - assert is_binary(Exception.message(error)) - # Should contain error type - assert Exception.message(error) =~ ~r/Error/i + # The plain message is the bare, single-line body — no location header. + msg = Exception.message(error) + assert is_binary(msg) + assert msg =~ "attempt to call a nil value" + refute msg =~ "at error_test.lua" + + # The rich formatter adds the location header and stays readable. + assert TypeError.format(error) =~ "at error_test.lua:3:" end end @@ -604,7 +608,9 @@ defmodule Lua.ErrorMessagesTest do assert e.line == 42 assert e.source == "explicit.lua" - assert Exception.message(e) =~ "explicit.lua:42" + # Location lives on the struct fields and in the rich render, not in the + # plain `Exception.message/1` body. + assert ArgumentError.format(e) =~ "explicit.lua:42" end test "RuntimeError raised from stdlib (e.g. select out of range) carries line and source" do diff --git a/test/lua/exception_ansi_gating_test.exs b/test/lua/exception_ansi_gating_test.exs index 30a92e65..3fa2daa8 100644 --- a/test/lua/exception_ansi_gating_test.exs +++ b/test/lua/exception_ansi_gating_test.exs @@ -1,13 +1,17 @@ defmodule Lua.ExceptionAnsiGatingTest do @moduledoc """ - Locks the fix for issue #384: exceptions must render their message when - `Exception.message/1` is called, gating ANSI on `IO.ANSI.enabled?/0` at that - point — never freezing a TTY-colored render at construction time. + Locks the ANSI contract for rendered errors: + + * `Exception.message/1` is the plain, log-safe form — it never emits ANSI + escapes, in any environment. + * `Lua.format_exception/1` (and each VM error's `format/1`) is the rich + form — it gates ANSI on `IO.ANSI.enabled?/0` evaluated *at render time*, + never frozen at construction (issue #384). The reverse direction proves + it is a gate, not a blanket strip. Each case builds the exception while ANSI is enabled (as it would be - constructed inside a TTY-attached VM execution), then renders with ANSI - disabled and asserts no escape codes survive. The reverse direction proves - it is a gate, not a blanket strip. + constructed inside a TTY-attached VM execution), then renders under both + settings. Toggling `:ansi_enabled` mutates global application env, so this module is `async: false` to avoid racing tests that assert on plain-text output. @@ -24,7 +28,7 @@ defmodule Lua.ExceptionAnsiGatingTest do end # Build each exception with ANSI enabled to simulate construction inside a - # TTY-attached VM. `message/1` must still consult the gate at call time. + # TTY-attached VM. The render must still consult the gate at call time. defp built_with_ansi_on(build) do Application.put_env(:elixir, :ansi_enabled, true) exception = build.() @@ -42,43 +46,61 @@ defmodule Lua.ExceptionAnsiGatingTest do defp build(:argument_error), do: ArgumentError.exception(function_name: "string.rep", arg_num: 2, expected: "number", source: "demo", line: 2) + defp rich(%RuntimeError{} = e), do: RuntimeError.format(e) + defp rich(%TypeError{} = e), do: TypeError.format(e) + defp rich(%AssertionError{} = e), do: AssertionError.format(e) + defp rich(%ArgumentError{} = e), do: ArgumentError.format(e) + for {name, key} <- [ {"RuntimeError", :runtime_error}, {"TypeError", :type_error}, {"AssertionError", :assertion_error}, {"ArgumentError", :argument_error} ] do - test "#{name}: message carries no ANSI when disabled at render time" do + test "#{name}: Exception.message/1 is ANSI-free regardless of env" do exception = built_with_ansi_on(fn -> build(unquote(key)) end) + refute Exception.message(exception) =~ "\e[" + + Application.put_env(:elixir, :ansi_enabled, true) + refute Exception.message(exception) =~ "\e[", + "#{unquote(name)}: Exception.message/1 must never emit ANSI" + end + + test "#{name}: format/1 carries no ANSI when disabled at render time" do + exception = built_with_ansi_on(fn -> build(unquote(key)) end) + + refute rich(exception) =~ "\e[", "#{unquote(name)} froze ANSI at construction (issue #384)" end - test "#{name}: message carries ANSI when enabled at render time" do + test "#{name}: format/1 carries ANSI when enabled at render time" do exception = built_with_ansi_on(fn -> build(unquote(key)) end) Application.put_env(:elixir, :ansi_enabled, true) - assert Exception.message(exception) =~ "\e[", "#{unquote(name)} should color on a TTY" + assert rich(exception) =~ "\e[", "#{unquote(name)} should color on a TTY" end end describe "Lua.RuntimeException wrapper" do - test "does not freeze the wrapped VM exception's ANSI render" do + test "message stays plain; format_exception gates ANSI at render time" do exception = built_with_ansi_on(fn -> Lua.RuntimeException.exception(RuntimeError.exception(value: "kaboom", source: "demo", line: 1)) end) refute Exception.message(exception) =~ "\e[" + refute Lua.format_exception(exception) =~ "\e[" Application.put_env(:elixir, :ansi_enabled, true) - assert Exception.message(exception) =~ "\e[" + refute Exception.message(exception) =~ "\e[" + assert Lua.format_exception(exception) =~ "\e[" end end describe "Lua.CompilerException (parse errors)" do - test "message gates ANSI at render time; :errors stays ANSI-free" do + test "message stays plain; :errors stays ANSI-free; format_exception gates ANSI" do exception = built_with_ansi_on(fn -> {:error, exception} = Lua.parse_chunk("asdf") @@ -89,9 +111,11 @@ defmodule Lua.ExceptionAnsiGatingTest do refute Enum.any?(exception.errors, &(&1 =~ "\e[")) refute Exception.message(exception) =~ "\e[" + refute Lua.format_exception(exception) =~ "\e[" Application.put_env(:elixir, :ansi_enabled, true) - assert Exception.message(exception) =~ "\e[" + refute Exception.message(exception) =~ "\e[" + assert Lua.format_exception(exception) =~ "\e[" end end end diff --git a/test/lua/parser/error_ansi_test.exs b/test/lua/parser/error_ansi_test.exs index 56846a62..031d5fda 100644 --- a/test/lua/parser/error_ansi_test.exs +++ b/test/lua/parser/error_ansi_test.exs @@ -31,14 +31,16 @@ defmodule Lua.Parser.ErrorAnsiTest do assert msg =~ "\e[36m" end - test "parse_chunk/1 renders a colored message when ANSI is on" do - # The `:errors` field stays ANSI-free (issue #384); color lives in the - # lazily-rendered message so the gate is honored at output time. + test "Lua.format_exception/1 renders a colored report when ANSI is on" do + # The `:errors` field and the plain `Exception.message/1` stay ANSI-free; + # color lives in the lazily-rendered `Lua.format_exception/1` report so + # the gate is honored at output time. assert {:error, %Lua.CompilerException{errors: errors} = exception} = Lua.parse_chunk("asdf") refute Enum.any?(errors, &(&1 =~ "\e[")) - assert Exception.message(exception) =~ "\e[" + refute Exception.message(exception) =~ "\e[" + assert Lua.format_exception(exception) =~ "\e[" end end end diff --git a/test/lua/runtime_exception_test.exs b/test/lua/runtime_exception_test.exs index dddd9128..588b6f08 100644 --- a/test/lua/runtime_exception_test.exs +++ b/test/lua/runtime_exception_test.exs @@ -710,4 +710,63 @@ defmodule Lua.RuntimeExceptionTest do assert MyApp.LuaHelper in modules end end + + defp arithmetic_error do + assert_raise RuntimeException, fn -> + Lua.eval!(Lua.new(), "local x = nil\nreturn x + 1", source: "demo.lua") + end + end + + describe "plain message, rich format, and structured to_map" do + test "Exception.message/1 is a single ANSI-free line with a location suffix" do + message = Exception.message(arithmetic_error()) + + assert message =~ "Lua runtime error: attempt to perform arithmetic on a nil value (local 'x')" + assert message =~ "(at demo.lua:2)" + refute message =~ "\n" + refute message =~ "\e[" + end + + test "Lua.format_exception/1 renders the rich multi-line report" do + rich = Lua.format_exception(arithmetic_error()) + + assert rich =~ "at demo.lua:2:" + assert rich =~ "Suggestion:" + assert rich =~ "\n" + end + + test "to_map/2 delegates to the wrapped VM error's structured shape" do + map = RuntimeException.to_map(arithmetic_error()) + + assert map.type == :type_error + assert map.line == 2 + assert map.source == "demo.lua" + assert map.message =~ "attempt to perform arithmetic" + refute map.message =~ "\e[" + end + + test "to_map/2 populates source_context when given source_code" do + code = "local x = nil\nreturn x + 1" + + error = + assert_raise RuntimeException, fn -> + Lua.eval!(Lua.new(), code, source: "demo.lua") + end + + map = RuntimeException.to_map(error, source_code: code) + + assert %{lines: [_ | _], pointer_column: _} = map.source_context + end + + test "to_map/2 returns a uniform minimal map for host-side (non-VM) errors" do + map = RuntimeException.to_map(RuntimeException.exception("cannot do that")) + + assert map.type == nil + assert map.message == "Lua runtime error: cannot do that" + assert map.source == nil + assert map.line == nil + assert map.call_stack == [] + assert map.source_context == nil + end + end end