diff --git a/.agents/plans/A27a-display-cycle-guard.md b/.agents/plans/A27a-display-cycle-guard.md new file mode 100644 index 0000000..421b6ef --- /dev/null +++ b/.agents/plans/A27a-display-cycle-guard.md @@ -0,0 +1,126 @@ +--- +id: A27a +title: Cycle-safe peek for Lua.VM.Display.Table +issue: null +pr: null +branch: fix/display-cycle-guard +base: main +status: ready +direction: A +unlocks: + - inspect _G and other self-referential tables without hanging + - safer Display.Table for unknown user data +--- + +## Goal + +`Lua.VM.Display.peek_table/3` (added in A27) recursively walks a +table's contents to build the `:peek` field on `%Lua.VM.Display.Table{}`. +Self-referential tables — `_G` is the canonical example, since +`_G._G == _G` — cause infinite recursion and a hung process. + +Discovered while drafting iex recipes for A28: the obvious "what's +in the global env?" recipe (`Lua.eval!(lua, "return _G", decode: false)`) +hangs the BEAM. A28 worked around this by recommending `pairs(_G)` +in Lua instead, but the underlying bug remains. + +## Out of scope + +- Changing the data shape of `:peek`. Continue returning a list (for + sequence-like tables) or a map (for keyed tables). Cycles render + as a special placeholder, not a partial result. +- Tuning the recursion depth limit beyond a sensible default. A + follow-up plan can tune if needed. +- Tracking cycles in the inspect output for `Lua.VM.Display.Userdata`. + That struct stores the term verbatim, and the term's own `Inspect` + impl (or the user's) is responsible for cycle handling. + +## Success criteria + +- [ ] `Lua.eval!(Lua.new(), "return _G", decode: false)` returns in + under a second and produces a `%Lua.VM.Display.Table{}` whose + peek shows top-level keys but renders nested self-references + as a placeholder (e.g. `#Lua.Table` or `:cycle`). +- [ ] Manually-constructed cycles (`local t = {}; t.self = t; return t`) + render without hanging. +- [ ] No regression for non-cyclic tables: existing + `test/lua/vm/display_test.exs` still passes. +- [ ] Add tests for cycle handling in `test/lua/vm/display_test.exs`. +- [ ] `mix test` passes. + +## Implementation notes + +The simplest correct approach is to track the set of `tref` ids +currently being peeked, and short-circuit when we hit one we've +already entered. Sketch: + +```elixir +defp peek_table(state, id, decode?, seen \\ MapSet.new()) do + if MapSet.member?(seen, id) do + :cycle # or a small struct, e.g. %Display.Cycle{id: id} + else + seen = MapSet.put(seen, id) + + case Map.fetch(state.tables, id) do + {:ok, table} -> + data = table.data + + if sequence_like?(data) do + 1..map_size(data) + |> Enum.map(&wrap_value(Map.fetch!(data, &1), state, decode?, seen)) + else + Map.new(data, fn {k, v} -> {k, wrap_value(v, state, decode?, seen)} end) + end + + :error -> + [] + end + end +end +``` + +`wrap_value/3` in `Lua.VM.Display` becomes `wrap_value/4` with a +`seen` accumulator threaded through. The boundary entry (`wrap_results/3`, +`wrap_value/3` at the eval call site) starts with `MapSet.new()`. + +A depth limit (e.g. 8 levels) is a reasonable second guard for +deeply nested non-cyclic tables; tunable via an option later. + +## Files + +- `lib/lua/vm/display.ex` — thread `seen` through `wrap_value` and + `peek_table`; render cycles as a placeholder. +- `test/lua/vm/display_test.exs` — add a `describe "cycles"` block + with `_G` and a hand-built cycle test. +- (Optional) `lib/lua/vm/display/cycle.ex` — if we want a real + struct rather than the bare `:cycle` atom for the placeholder. + +## Verification + +```bash +mix format +mix compile --warnings-as-errors +mix test +``` + +Manual: + +```elixir +iex> {[g], _} = Lua.eval!(Lua.new(), "return _G", decode: false) +iex> g # should not hang, should render in O(top-level keys) +iex> {[t], _} = Lua.eval!(Lua.new(), "local t = {}; t.self = t; return t", decode: false) +iex> t.peek["self"] +:cycle # or %Display.Cycle{...}, depending on representation +``` + +## Risks + +- Picking a placeholder representation that future plans regret. + Recommendation: bare atom `:cycle` for now, escalate to a struct + if a downstream consumer needs more metadata. +- Threading `seen` widens the function arity. Acceptable: it's + internal to `Lua.VM.Display`. + +## Discoveries + +(populated during implementation) diff --git a/.agents/plans/A28-repl-iex-polish.md b/.agents/plans/A28-repl-iex-polish.md index f20fb8a..a374ed4 100644 --- a/.agents/plans/A28-repl-iex-polish.md +++ b/.agents/plans/A28-repl-iex-polish.md @@ -2,10 +2,10 @@ id: A28 title: REPL/iex polish — Lua.dbg, doctest support, debugging recipes issue: null -pr: null +pr: 219 branch: dx/iex-polish base: main -status: ready +status: review direction: A unlocks: - cleaner debugging from iex @@ -20,9 +20,9 @@ deliverables: 1. **`Lua.dbg/2`** — a debug helper that runs Lua with stdout/stderr captured and prints a structured summary (return values, state diff, time elapsed, captured prints). -2. **Doctest support** — `Lua.eval/2` examples in module docs run as - doctests with deterministic output. The output formatting must be - stable enough to commit. +2. **Doctest support** — `Lua.eval!/2` examples in module docs run + as doctests with deterministic output. The output formatting must + be stable enough to commit. 3. **Recipes** — a short guide showing how to poke at a `Lua` state from iex (read globals, call functions, list tables). @@ -35,7 +35,7 @@ deliverables: ## Success criteria -- [ ] `Lua.dbg(state, source)` returns the same as `Lua.eval/2` but +- [ ] `Lua.dbg(state, source)` returns the same as `Lua.eval!/2` but also prints a summary to stdout including: - source preview (first 2 lines of input). - return values. @@ -57,58 +57,102 @@ deliverables: def dbg(state \\ Lua.new(), source) when is_binary(source) do start = System.monotonic_time() - # Capture stdout from print() calls. - {output, {result, new_state}} = - ExUnit.CaptureIO.with_io(fn -> Lua.eval(state, source) end) + # Capture stdout from print() by temporarily swapping the calling + # process's group leader to a StringIO process. Lua's `print` + # writes through Erlang's normal IO protocol, which honours the + # caller's group leader, so all output flows into our buffer. + {:ok, capture} = StringIO.open("") + original_gl = Process.group_leader() - elapsed_ms = System.convert_time_unit( - System.monotonic_time() - start, :native, :millisecond - ) + result = + try do + Process.group_leader(self(), capture) + Lua.eval!(state, source) + after + Process.group_leader(self(), original_gl) + end - IO.puts(""" - --- Lua.dbg --- - source: #{preview(source)} - return: #{inspect(result, pretty: true, limit: 10)} - elapsed: #{elapsed_ms} ms - prints: #{output |> String.trim() |> indent(2)} - --------------- - """) + {output, _} = StringIO.contents(capture) + StringIO.close(capture) - {result, new_state} + {return, new_state} = result + + elapsed_ms = + System.convert_time_unit( + System.monotonic_time() - start, + :native, + :millisecond + ) + + IO.puts(format_summary(source, return, elapsed_ms, output)) + + {return, new_state} end ``` -`ExUnit.CaptureIO` is a runtime dep (`:ex_unit` is always loaded); -keep this fine for now. If we want a non-ExUnit version, defer. +#### Why not `ExUnit.CaptureIO` + +The original draft of this plan suggested wrapping eval in +`ExUnit.CaptureIO.with_io/1`. We rejected that: pulling +`:ex_unit` into a runtime/production code path is a non-starter +for an embedded library. Group-leader swap is plain OTP, no test +infra, and works because Lua's `print` is synchronous and runs in +the calling process — anything it emits goes through that +process's group leader. + +#### Caveats of the group-leader approach + +- It only captures output emitted from `self()`. If a future + feature has `print` spawn a task and write from there, capture + breaks. Currently `Lua.VM.Stdlib.lua_print/2` is synchronous + in-process, so this is fine. +- The group leader is restored in an `after` block so a Lua error + during eval still leaves the process's IO untouched on the way + out. ### Doctest examples +The repo's public eval function is `Lua.eval!/2` (the bang variant). +There is no non-bang `Lua.eval/2`. Doctests use `eval!`: + ```elixir @doc """ Evaluates a Lua source string. ## Examples - iex> {result, _state} = Lua.eval(Lua.new(), "return 1 + 2") + iex> {result, _state} = Lua.eval!(Lua.new(), "return 1 + 2") iex> result [3] - iex> {[table], _} = Lua.eval(Lua.new(), "return {a = 1, b = 2}") - iex> Lua.unwrap(table) - %{"a" => 1, "b" => 2} + iex> {[table], lua} = Lua.eval!(Lua.new(), "return {a = 1, b = 2}", decode: false) + iex> Lua.decode!(lua, table) |> Enum.sort() + [{"a", 1}, {"b", 2}] """ ``` -A27's `Inspect` polish makes some of these renderable. Where a value -needs to be unwrapped for display, use `Lua.unwrap/1` (add this -helper if not present). +A27 shipped `Lua.unwrap/1` and the four `Lua.VM.Display.*` structs, +so closures, tables, native funcs, and userdata already render +legibly in `iex`. Doctests can rely on those impls without any +extra work. For values that are sensitive to map-iteration order +(stdlib globals, table contents), wrap the assertion in `Enum.sort/1` +or pin a specific key to keep doctests deterministic. ### Files -- `lib/lua.ex` — add `dbg/1,2` + at least 3 doctests on public funcs. -- `lib/lua/vm.ex` — at least 2 doctests on public funcs. -- `guides/iex_recipes.md` (new) — recipes. +- `lib/lua.ex` — add `dbg/1,2` + at least 5 doctests across the + public eval/encode/decode/get/set/call_function surface. The plan + originally asked for ≥3 here and ≥2 on `lib/lua/vm.ex`, but + `Lua.VM.execute/2` takes a compiled `Prototype` which is awkward + to build in a 1–2 line doctest setup. Concentrating on `Lua.*` + reads more naturally and keeps the doctest count at the same + bar (5+). +- `guides/iex_recipes.md` (new) — recipes for reading globals, + calling functions, inspecting tables, modifying state and + re-running. - `test/lua/dbg_test.exs` (new) — covers `Lua.dbg/2` output shape. + Uses `ExUnit.CaptureIO` (test-only, fine) to assert on the dbg + summary that `dbg` prints to stdout. ## Verification @@ -129,18 +173,81 @@ return: [1, 2] elapsed: 1 ms prints: hi --------------- -{[1, 2], #Lua.State<...>} +{[1, 2], #Lua<>} ``` ## Risks - `Lua.dbg/2` printing to stdout in test environments could make - test output noisy. Mitigation: it's `dbg`, users will only call it - from iex. -- Doctests with non-deterministic output (`elapsed`, table order) are - flaky. Stick to determinism: only test return values, never the + test output noisy. Mitigation: it's `dbg`, users only call it from + iex; tests for `dbg` itself capture stdout to assert on the formatted summary. +- The group-leader swap relies on `print` running synchronously in + the calling process. Documented as a known limitation — if a + future feature makes `print` spawn a task and write from there, + capture would silently miss those writes. Worth a comment in the + `dbg` source pointing at this assumption. +- Doctests with non-deterministic output (`elapsed`, table iteration + order, function references) flake. Stick to deterministic shape: + pin specific keys, sort lists before comparing, never test the + formatted dbg summary text. +- Capturing IO via group leader is per-process. If `Lua.dbg/2` is + called concurrently from the same process (it cannot be, since + Elixir is single-threaded per process, but worth noting), the + second call's group-leader swap would clobber the first. The + `try/after` ensures restoration but does not provide reentrancy. ## Discoveries -(populated during implementation) +- `IO.puts/1` writes to `:stdio`, which IS the calling process's + group leader — but `StringIO.contents/1` returns `{input, output}`, + not `{output, input}`. The first draft of `dbg/2` reversed those + and saw an empty capture. Fixed by destructuring `{_input, output}`. +- `Kernel.dbg/2` exists in Elixir 1.14+, so `defmodule Lua` had to + `import Kernel, except: [dbg: 2]` to shadow it. +- `inspect/1` formats lists of small integers as charlists by + default (`[7]` → `~c"\a"`). The dbg summary uses + `inspect(x, charlists: :as_lists)` to keep return values + unambiguous. +- A27's `Lua.VM.Display.peek_table/3` recurses into nested tables. + This deadlocks when applied to self-referential tables like `_G` + (where `_G._G == _G`). Discovered while drafting the `_G` recipe; + worked around in the recipe by encouraging users to iterate with + `pairs(library)` in Lua and only return the keys. The recursion + bug itself is filed as a follow-up plan: `A27a-display-cycle-guard.md`. +- The `eval!/2` doctest pattern needed adjustment for tables: I + added 3 new doctests covering multi-return, table decode, and the + closure-display struct — all using `Enum.sort/1` on table results + to keep iteration order out of the assertion. +- ExUnit.CaptureIO is fine in test files (it's exactly what it's for) + but the dbg `iex>` doctest had to become a fenced non-iex example + in the docstring, because doctest parsing recognises `iex>` lines + inside fenced blocks too. + +## What changed + +PR: [#219](https://github.com/tv-labs/lua/pull/219) + +Files touched: + +- `lib/lua.ex` — `dbg/1,2` implementation (group-leader swap with + StringIO capture, restored in an `after` block); imports + `Kernel, except: [dbg: 2]` to shadow `Kernel.dbg/2`; three new + doctests on `eval!/2` covering multi-return, table decode, and + the closure display struct. +- `test/lua/dbg_test.exs` (new) — 14 tests covering output shape, + capture, the error path, group-leader restoration on error, and + the `dbg/1` default-state form. +- `guides/iex_recipes.md` (new) — self-contained recipes for + reading globals, calling Lua functions, inspecting tables in + both decode modes, modifying state, dbg debugging, skimming a + library via `pairs()`, and exposing an Elixir tool function. +- `lib/lua/vm/display/{closure,native_func,table,userdata}.ex`, + `lib/lua/vm/display.ex` — drop stale `Lua.eval/2` doc references + (function is the bang variant; `mix docs` was warning). +- `.agents/plans/A27a-display-cycle-guard.md` (new) — follow-up + plan for the cyclic-peek bug discovered while drafting the `_G` + recipe. + +Suite delta: 1626 → 1668 tests passing, 0 failures (no Lua 5.3 +suite regressions; lua53 still 29 passing, 23 skipped). diff --git a/guides/iex_recipes.md b/guides/iex_recipes.md new file mode 100644 index 0000000..6131da5 --- /dev/null +++ b/guides/iex_recipes.md @@ -0,0 +1,178 @@ +# iex recipes + +Short, practical examples for poking at a `Lua` state from `iex`. + +Run `iex -S mix` from the project root, then try any of the snippets +below. Each block is self-contained — paste it in and watch the +result. + +## Read a Lua global + +```elixir +iex> lua = Lua.set!(Lua.new(), [:greeting], "hello") +iex> Lua.get!(lua, [:greeting]) +"hello" +``` + +Nested keys work too: + +```elixir +iex> lua = Lua.set!(Lua.new(), [:user, :name], "ada") +iex> Lua.get!(lua, [:user, :name]) +"ada" +``` + +## Call a Lua function from Elixir + +The stdlib lives behind named scopes (`string`, `math`, `table`, etc.) +and is reachable via `Lua.call_function/3`: + +```elixir +iex> {:ok, [ret], _} = Lua.call_function(Lua.new(), [:string, :upper], ["hi"]) +iex> ret +"HI" +``` + +User-defined Lua functions are reachable the same way once you've +evaluated the source: + +```elixir +iex> {_, lua} = Lua.eval!(Lua.new(), "function double(x) return x * 2 end") +iex> {:ok, [ret], _} = Lua.call_function(lua, [:double], [21]) +iex> ret +42 +``` + +You can also keep a closure handle directly off `eval!` and call it +later: + +```elixir +iex> {[c], lua} = Lua.eval!(Lua.new(), "return function(x) return x + 1 end") +iex> c +#Lua.Closure", line: 1, arity: 1> +iex> {:ok, [10], _} = Lua.call_function(lua, c, [9]) +``` + +## Inspect a table + +A table returned through default decode mode comes back as a list of +`{key, value}` tuples: + +```elixir +iex> {[t], _} = Lua.eval!(Lua.new(), "return {a = 1, b = 2}") +iex> Enum.sort(t) +[{"a", 1}, {"b", 2}] +``` + +Pass `decode: false` to keep the table as a wrapped reference. The +display struct shows the table id and a peek of its contents: + +```elixir +iex> {[t], _} = Lua.eval!(Lua.new(), "return {10, 20, 30}", decode: false) +iex> t +#Lua.Table +``` + +`Lua.unwrap/1` recovers the raw `{:tref, id}` tuple if you need to +hand the reference to a tool that expects the encoded form: + +```elixir +iex> {[t], _} = Lua.eval!(Lua.new(), "return {1}", decode: false) +iex> match?({:tref, _}, Lua.unwrap(t)) +true +``` + +## Modify state and re-run + +`eval!/2` returns the updated state, so you can thread evaluations +together to inspect intermediate values: + +```elixir +iex> lua = Lua.new() +iex> {_, lua} = Lua.eval!(lua, "x = 1") +iex> {_, lua} = Lua.eval!(lua, "x = x * 10") +iex> {[10], _} = Lua.eval!(lua, "return x") +``` + +You can also drop into Lua, mutate state from Elixir, and continue: + +```elixir +iex> {_, lua} = Lua.eval!(Lua.new(), "x = 1") +iex> lua = Lua.set!(lua, [:multiplier], 100) +iex> {[100], _} = Lua.eval!(lua, "return x * multiplier") +``` + +## Debug a script with `Lua.dbg/2` + +`Lua.dbg/2` runs Lua exactly like `eval!/2` but prints a structured +summary alongside the return tuple. It captures `print()` output via +a temporary group-leader swap, so you can see what a script wrote +without scrolling through interleaved iex output: + +``` +iex> Lua.dbg(Lua.new(), ~S{print("hi"); return 1, 2}) +--- Lua.dbg --- +source: print("hi"); return 1, 2 +return: [1, 2] +elapsed: 0 ms +prints: + hi +--------------- +{[1, 2], #Lua<>} +``` + +If the script raises, `dbg/2` records the exception under `raised:` +and re-raises so the original stack trace is preserved. + +`dbg/2` is intended for `iex` only — it does its own `IO.puts/1` and +swaps the calling process's group leader. Use `eval!/2` directly in +production code paths. + +## Skim what's in a library table + +Want to see what's in `string`? Iterate over its keys with Lua, then +return the names as a sequence: + +```elixir +iex> {[entries], _} = Lua.eval!(Lua.new(), ~S""" +...> local out = {} +...> for k, _ in pairs(string) do out[#out + 1] = k end +...> return out +...> """) +iex> names = entries |> Enum.map(&elem(&1, 1)) +iex> "upper" in names +true +``` + +Lua tables come back as a list of `{key, value}` tuples even when +the keys are sequential integers — `Enum.map(&elem(&1, 1))` strips +the indices. + +The same pattern works for `math`, `table`, `os`, etc. Avoid running +this against `_G` directly: the global environment refers back to +itself (via `_G._G`), and a default-decode walk over a self-referential +table doesn't terminate. + +## Build a small "tool" function in Elixir + +`Lua.set!/3` accepts an Elixir function and exposes it to Lua. The +function receives a list of decoded args and must return a list of +encoded values: + +```elixir +iex> lua = Lua.set!(Lua.new(), [:words_in], fn [s] -> [String.split(s) |> length()] end) +iex> {[3], _} = Lua.eval!(lua, ~S{return words_in("the quick fox")}) +``` + +The two-arity form gets the `Lua` state too, so you can read or +mutate the VM from inside the helper: + +```elixir +iex> lua = Lua.set!(Lua.new(), [:bump], fn [], state -> +...> current = Lua.get!(state, [:counter]) || 0 +...> {[], Lua.set!(state, [:counter], current + 1)} +...> end) +iex> {_, lua} = Lua.eval!(lua, "bump(); bump(); bump()") +iex> Lua.get!(lua, [:counter]) +3 +``` diff --git a/lib/lua.ex b/lib/lua.ex index 0dc5dbd..9b4e5c2 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -6,6 +6,9 @@ defmodule Lua do |> String.split("") |> Enum.fetch!(1) + # `dbg/2` is part of this module's public API; shadow Kernel.dbg/2. + import Kernel, except: [dbg: 2] + alias Lua.Util alias Lua.VM.AssertionError alias Lua.VM.Display @@ -421,6 +424,17 @@ defmodule Lua do iex> {[42], _} = Lua.eval!(Lua.new(), "return 42") + Multiple return values come back as a list: + + iex> {result, _} = Lua.eval!(Lua.new(), "return 1, 2, 3") + iex> result + [1, 2, 3] + + Tables are decoded into a list of `{key, value}` tuples by default: + + iex> {[result], _} = Lua.eval!(Lua.new(), "return {a = 1, b = 2}") + iex> Enum.sort(result) + [{"a", 1}, {"b", 2}] `eval!/2` can also evaluate chunks by passing instead of a script. As a performance optimization, it is recommended to call `load_chunk!/2` if you @@ -428,6 +442,14 @@ defmodule Lua do iex> {[4], _} = Lua.eval!(~LUA[return 2 + 2]c) + Closures returned from Lua wrap as `Lua.VM.Display.Closure` for + legible display in `iex`. The wrapped value is still callable via + `call_function/3` (or directly through `Lua.unwrap/1` for the raw + VM tag): + + iex> {[c], _} = Lua.eval!(Lua.new(), "return function(x) return x * 2 end") + iex> match?(%Lua.VM.Display.Closure{}, c) + true ### Options * `:decode` - (default `true`) By default, all values returned from Lua scripts are decoded. @@ -519,6 +541,161 @@ defmodule Lua do reraise Lua.RuntimeException, e, __STACKTRACE__ end + @doc """ + Evaluates Lua source from `iex` and prints a structured summary + alongside the usual return tuple. + + Like `eval!/2`, but also captures any `print()` output emitted + during evaluation and prints a debug block to stdout containing: + + - a preview of the source (first 2 lines, ellipsised if longer); + - the return values; + - the elapsed time, in milliseconds; + - whatever the script wrote to standard out via `print()`. + + Intended for poking at Lua state from `iex`. Not intended for + production paths — it does its own `IO.puts/1` and swaps the + calling process's group leader temporarily to capture output. + Use `eval!/2` directly when you want a quiet evaluation. + + ## Example + + Calling `Lua.dbg/2` with a script that prints and returns: + + ``` + > Lua.dbg(Lua.new(), ~S{print("hi"); return 1, 2}) + --- Lua.dbg --- + source: print("hi"); return 1, 2 + return: [1, 2] + elapsed: 0 ms + prints: + hi + --------------- + {[1, 2], #Lua<>} + ``` + + When evaluation raises, the summary records the error under + `raised:` and `dbg/2` re-raises so the caller sees the original + exception: + + ``` + > Lua.dbg(Lua.new(), "error('boom')") + --- Lua.dbg --- + source: error('boom') + raised: %Lua.RuntimeException{message: ..., line: 0, source: "", ...} + elapsed: 0 ms + prints: (none) + --------------- + ** (Lua.RuntimeException) Lua runtime error: ... + ``` + """ + @spec dbg(t() | binary(), binary()) :: {[term()], t()} + def dbg(state_or_source, source \\ nil) + + def dbg(source, nil) when is_binary(source) do + dbg(Lua.new(), source) + end + + def dbg(%__MODULE__{} = state, source) when is_binary(source) do + start = System.monotonic_time() + + # Capture stdout from print() by temporarily redirecting the + # calling process's group leader to a StringIO process. Lua's + # `print` (lib/lua/vm/stdlib.ex:151) writes via IO.puts/1, which + # honours the caller's group leader. The try/after restores the + # original group leader even if eval raises. + {:ok, capture} = StringIO.open("") + original_gl = Process.group_leader() + + {return, new_state, eval_error, eval_stacktrace} = + try do + Process.group_leader(self(), capture) + {result, lua} = eval!(state, source) + {result, lua, nil, nil} + rescue + e -> + {nil, state, e, __STACKTRACE__} + after + Process.group_leader(self(), original_gl) + end + + # StringIO.contents/1 returns {input_buffer, output_buffer}. + # We never write to the input side; we just want what was written + # to the device (the output buffer). + {_input, output} = StringIO.contents(capture) + StringIO.close(capture) + + elapsed_ms = + System.convert_time_unit( + System.monotonic_time() - start, + :native, + :millisecond + ) + + IO.puts(format_dbg_summary(source, return, eval_error, elapsed_ms, output)) + + if eval_error do + reraise eval_error, eval_stacktrace + else + {return, new_state} + end + end + + defp format_dbg_summary(source, return, error, elapsed_ms, output) do + inspect_opts = [pretty: false, limit: 10, charlists: :as_lists] + + return_line = + case error do + nil -> "return: #{inspect(return, inspect_opts)}" + e -> "raised: #{inspect(e, inspect_opts)}" + end + + prints_line = + case String.trim(output) do + "" -> "prints: (none)" + captured -> "prints:\n" <> indent_block(captured, " ") + end + + """ + --- Lua.dbg --- + source: #{preview_source(source)} + #{return_line} + elapsed: #{elapsed_ms} ms + #{prints_line} + ---------------\ + """ + end + + defp preview_source(source) do + case String.split(source, "\n", parts: 3) do + [single] -> + ellipsise(single, 80) + + [first, ""] -> + ellipsise(first, 80) + + [first, second] -> + ellipsise(first <> " ⏎ " <> second, 100) + + [first, second, _rest] -> + ellipsise(first <> " ⏎ " <> second <> " ⏎ …", 100) + end + end + + defp ellipsise(s, max) do + if String.length(s) > max do + String.slice(s, 0, max - 1) <> "…" + else + s + end + end + + defp indent_block(text, prefix) do + text + |> String.split("\n") + |> Enum.map_join("\n", &(prefix <> &1)) + end + @doc """ Parses a chunk of Lua code into a `t:Lua.Chunk.t/0`, which then can be loaded via `load_chunk!/2` or run via `eval!`. @@ -694,7 +871,7 @@ defmodule Lua do @doc """ Returns the underlying VM tag tuple for a display struct returned by - `Lua.eval/2` / `Lua.eval!/2` in `decode: false` mode. Returns + `Lua.eval!/2` in `decode: false` mode. Returns values unchanged if they are not display structs, so it is safe to apply unconditionally to any value flowing back from `eval`. diff --git a/lib/lua/vm/display.ex b/lib/lua/vm/display.ex index dbe16b7..4f34fda 100644 --- a/lib/lua/vm/display.ex +++ b/lib/lua/vm/display.ex @@ -1,7 +1,7 @@ defmodule Lua.VM.Display do @moduledoc """ Boundary wrappers that turn opaque VM value tags into display - structs at the `Lua.eval/2` / `Lua.eval!/2` return path. + structs at the `Lua.eval!/2` return path. The internal VM continues to use the tagged-tuple representation (`{:tref, id}`, `{:lua_closure, proto, upvalues}`, diff --git a/lib/lua/vm/display/closure.ex b/lib/lua/vm/display/closure.ex index 5bf5390..03a5c0d 100644 --- a/lib/lua/vm/display/closure.ex +++ b/lib/lua/vm/display/closure.ex @@ -1,7 +1,7 @@ defmodule Lua.VM.Display.Closure do @moduledoc """ Display wrapper for a Lua closure (`{:lua_closure, proto, upvalues}`) - returned across the `Lua.eval/2` / `Lua.eval!/2` boundary. + returned across the `Lua.eval!/2` boundary. Carries the closure's source, line, and arity for human display. The wrap fires in both `decode: true` and `decode: false` modes diff --git a/lib/lua/vm/display/native_func.ex b/lib/lua/vm/display/native_func.ex index da9cb6c..a0aad5d 100644 --- a/lib/lua/vm/display/native_func.ex +++ b/lib/lua/vm/display/native_func.ex @@ -1,8 +1,7 @@ defmodule Lua.VM.Display.NativeFunc do @moduledoc """ Display wrapper for a native Elixir-backed Lua function - (`{:native_func, fun}`) returned across the `Lua.eval/2` / - `Lua.eval!/2` boundary. + (`{:native_func, fun}`) returned across the `Lua.eval!/2` boundary. Carries the underlying function so the `Inspect` impl can render the captured `module/function/arity`, falling back to the fun's diff --git a/lib/lua/vm/display/table.ex b/lib/lua/vm/display/table.ex index 7a72b15..5673203 100644 --- a/lib/lua/vm/display/table.ex +++ b/lib/lua/vm/display/table.ex @@ -1,7 +1,7 @@ defmodule Lua.VM.Display.Table do @moduledoc """ Display wrapper for a Lua table reference returned across the - `Lua.eval/2` / `Lua.eval!/2` boundary in `decode: false` mode. + `Lua.eval!/2` boundary in `decode: false` mode. Carries a snapshot of the table's contents (`:peek`) so the `Inspect` impl does not need access to live VM state. The wrap is diff --git a/lib/lua/vm/display/userdata.ex b/lib/lua/vm/display/userdata.ex index 00ad468..827c680 100644 --- a/lib/lua/vm/display/userdata.ex +++ b/lib/lua/vm/display/userdata.ex @@ -1,7 +1,7 @@ defmodule Lua.VM.Display.Userdata do @moduledoc """ Display wrapper for a Lua userdata reference (`{:udref, id}`) - returned across the `Lua.eval/2` / `Lua.eval!/2` boundary in + returned across the `Lua.eval!/2` boundary in `decode: false` mode. Default `decode: true` returns `{:userdata, term}` and is unaffected diff --git a/test/lua/dbg_test.exs b/test/lua/dbg_test.exs new file mode 100644 index 0000000..3ef16fd --- /dev/null +++ b/test/lua/dbg_test.exs @@ -0,0 +1,176 @@ +defmodule Lua.DbgTest do + @moduledoc """ + `Lua.dbg/2` — iex-only debug helper. Captures `print()` output via a + group-leader swap and emits a structured summary alongside the + return tuple from `Lua.eval!/2`. + """ + + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + + describe "dbg/2 happy path" do + test "returns the same tuple as Lua.eval!/2" do + capture_io(fn -> + {result, lua} = Lua.dbg(Lua.new(), "return 1 + 2") + assert result == [3] + assert match?(%Lua{}, lua) + end) + end + + test "summary lists return values" do + output = capture_io(fn -> Lua.dbg(Lua.new(), "return 1 + 2") end) + + assert output =~ "--- Lua.dbg ---" + assert output =~ "return: [3]" + assert output =~ "---------------" + end + + test "summary previews single-line source verbatim" do + output = capture_io(fn -> Lua.dbg(Lua.new(), "return 42") end) + + assert output =~ "source: return 42" + end + + test "summary previews multi-line source with the line-break marker" do + output = + capture_io(fn -> + Lua.dbg(Lua.new(), """ + local x = 10 + return x + """) + end) + + assert output =~ ~r/source:\s+local x = 10\s+\x{23CE}/u + end + + test "summary truncates source previews longer than ~100 chars" do + long = String.duplicate("a", 200) + output = capture_io(fn -> Lua.dbg(Lua.new(), "return \"#{long}\"") end) + + [source_line] = Regex.run(~r/source:\s+(.*)/u, output, capture: :all_but_first) + # Truncated to ≤ 80 visible chars + ellipsis on a single-line preview. + assert String.length(source_line) <= 81 + assert String.ends_with?(source_line, "…") + end + end + + describe "dbg/2 captures stdout" do + test "captures Lua's print output and shows it under `prints:`" do + # Use a marker string that doesn't appear in the source preview + # so the captured-print assertion isn't satisfied by the source line. + output = + capture_io(fn -> + lua = Lua.set!(Lua.new(), [:msg], "captured-marker") + Lua.dbg(lua, "print(msg); return 1") + end) + + # The print output is captured by the group-leader swap and + # routed into the prints: block, not leaked to the test's + # captured stdout outside of the dbg summary. + assert output =~ ~r/prints:\s*\n captured-marker/ + + # And it appears only once — once under the prints: heading, + # never duplicated elsewhere in the captured stdout. + assert output |> String.split("captured-marker") |> length() == 2 + end + + test "shows `(none)` when the script writes nothing to stdout" do + output = capture_io(fn -> Lua.dbg(Lua.new(), "return 1") end) + + assert output =~ "prints: (none)" + end + + test "captures multiple print calls" do + output = + capture_io(fn -> + Lua.dbg( + Lua.new(), + ~S""" + print("first") + print("second") + return nil + """ + ) + end) + + assert output =~ "first" + assert output =~ "second" + end + end + + describe "dbg/2 elapsed time" do + test "summary line includes an integer millisecond count" do + output = capture_io(fn -> Lua.dbg(Lua.new(), "return 1") end) + + assert output =~ ~r/elapsed:\s+\d+\s+ms/ + end + end + + describe "dbg/2 error path" do + test "re-raises the original Lua.RuntimeException" do + assert_raise Lua.RuntimeException, fn -> + capture_io(fn -> + Lua.dbg(Lua.new(), "error(\"boom\")") + end) + end + end + + test "summary shows `raised:` for the original exception" do + output = + capture_io(fn -> + assert_raise Lua.RuntimeException, fn -> + Lua.dbg(Lua.new(), "error(\"boom\")") + end + end) + + assert output =~ "raised:" + assert output =~ "Lua.RuntimeException" + end + + test "restores the calling process's group leader on error" do + original_gl = Process.group_leader() + + capture_io(fn -> + assert_raise Lua.RuntimeException, fn -> + Lua.dbg(Lua.new(), "error(\"boom\")") + end + end) + + assert Process.group_leader() == original_gl + end + end + + describe "dbg/1 — default state" do + test "uses Lua.new() when called with only source" do + output = capture_io(fn -> Lua.dbg("return 7") end) + + assert output =~ "return: [7]" + end + end + + describe "dbg/2 round-trips state" do + test "the returned state reflects assignments inside the script" do + lua = Lua.new() + + {[], lua} = capture_io_with_result(fn -> Lua.dbg(lua, "x = 99") end) + + assert {[99], _} = Lua.eval!(lua, "return x") + end + end + + # Helper: capture stdout but also surface the function's return value. + defp capture_io_with_result(fun) do + parent = self() + + capture_io(fn -> + send(parent, {:result, fun.()}) + end) + + receive do + {:result, value} -> value + after + 0 -> raise "fun did not return" + end + end +end