Completing the Lua 5.3 VM
Context
Phases 0-10 built the new VM (parser, compiler, executor, stdlib, value encoding/decoding). Phase 11+13 removed Luerl and integrated the new VM into the public Lua API. We now have 900 passing tests with 25 pending and 0 failures.
The VM works for core features but lacks: stack traces, pcall, string/math/table stdlib, metatables, userdata, require/package, and polished error messages. This plan completes the VM by implementing these features in dependency order, integrating the official Lua 5.3 test suite, and delivering beautiful error reporting.
Phase 1: Source Line Tracking & Call Stack
Goal: Compiler emits debug info, executor tracks it at runtime. Foundation for all error reporting.
1A. Compiler: Emit source_line Instructions
File: lib/lua/compiler/codegen.ex
Every AST statement node has meta.start.line. Prepend a source_line(line, source) instruction before each statement in gen_block/2:
defp emit_source_line(%{meta: %{start: %{line: line}}}, ctx) when is_integer(line) do [Instruction.source_line(line, ctx.source)] end defp emit_source_line(_, _), do: []
Update the Prototype lines field to track {first_line, last_line} from the block's statements instead of hardcoding {1, 1}.
1B. Executor: Handle source_line & Track Current Line
File: lib/lua/vm/executor.ex
Add handler clause: defp do_execute([{:source_line, line, _file} | rest], regs, upvalues, proto, state) do state = %{state | current_line: line} do_execute(rest, regs, upvalues, proto, state) end
1C. Call Stack Push/Pop on Function Calls
File: lib/lua/vm/executor.ex (:call handler at line 328)
Before executing a callee, push a frame. After, pop it: frame = %{source: proto.source, line: state.current_line, name: func_name} state = %{state | call_stack: [frame | state.call_stack]}
state = %{state | call_stack: tl(state.call_stack)}
1D. State Struct Updates
File: lib/lua/vm/state.ex
Add fields: current_line: 0, current_source: nil to the struct and typespec.
Files Modified
- lib/lua/compiler/codegen.ex — emit source_line before statements, compute line ranges
- lib/lua/vm/executor.ex — handle source_line instruction, push/pop call stack frames
- lib/lua/vm/state.ex — add current_line, current_source fields
Phase 2: Beautiful Stack Traces & Error Messages
Goal: Rich, ANSI-colored error output following the pattern in lib/lua/parser/error.ex.
2A. Implement format_stacktrace in Lua.Util
File: lib/lua/util.ex
Replace the stub (def format_stacktrace(_, _, _), do: "") with real formatting. The call stack is a list of %{source: str, line: int, name: str | nil} maps. Output format:
stack traceback: -no-source-:3: in function 'bar' -no-source-:7: in function 'foo' -no-source-:10: in main chunk
This matches Lua 5.3's native debug.traceback() format.
2B. Create Runtime Error Formatter
File: NEW lib/lua/vm/error_formatter.ex
Module Lua.VM.ErrorFormatter following the Lua.Parser.Error pattern:
- ANSI-colored error type header
- Clear error message
- Source context with line numbers and ^ pointer
- Stack trace
- Suggestions for common mistakes (calling nil, arithmetic on strings, indexing non-tables)
2C. Attach Call Stack to VM Errors
Files: lib/lua/vm/type_error.ex, lib/lua/vm/runtime_error.ex, lib/lua/vm/assertion_error.ex
Add optional call_stack: [] and line: nil fields. When executor raises, include the current state's call stack.
2D. Enhance RuntimeException & CompilerException
Files: lib/lua/runtime_exception.ex, lib/lua/compiler_exception.ex
Include formatted stack traces in exception messages. Restore the TODO at compiler_exception.ex:26.
2E. Comprehensive Error Test Suite
File: NEW test/lua/error_messages_test.exs
Test every error case for beautiful output:
- Calling nil / non-function values
- Arithmetic on non-numbers ("string" + 5)
- Indexing non-tables (local x = 5; x.foo)
- String concat with non-strings
- Division by zero
- Stack overflow (deep recursion)
- Undefined global access (calling undefined function)
- Comparison of incompatible types
- Wrong number of arguments
- Each test asserts the error message contains source location, stack trace, and clear description
Tests Unblocked (10)
- util_test.exs:60 — "it pretty prints a stacktrace"
- util_test.exs:77 — "it can show function arities"
- lua_test.exs:122 — "loading files with illegal tokens returns an error"
- lua_test.exs:157 — "loading files with syntax errors returns an error"
- lua_test.exs:179 — "loading files with undefined functions returns an error"
- lua_test.exs:339 — "invalid functions raise"
- lua_test.exs:690 — "calling non-functions raises"
- lua_test.exs:748 — "function doesn't exist"
- lua_test.exs:847 — "function doesn't exist in nested function"
- lua_test.exs:876 — "api function that doesn't exist"
Phase 3: pcall/xpcall
Goal: Protected calls for error recovery, essential for robust Lua programs.
3A. Add pcall/xpcall to Stdlib
File: lib/lua/vm/stdlib.ex
pcall(f, ...) — call f in protected mode. Returns true, results... on success, false, error_msg on failure.
Implementation: native function that calls the closure through Lua.VM.Executor.execute/5 wrapped in try/rescue:
defp lua_pcall([{:lua_closure, proto, upvalues} | args], state) do try do {results, _regs, state} = call_closure(proto, upvalues, args, state) {[true | results], state} rescue e in [RuntimeError, TypeError, AssertionError] -> {[false, extract_error_value(e)], state} end end
Also handle {:native_func, fun} callees. xpcall(f, handler, ...) calls handler(msg) on error.
3B. Extract Call Helper
Refactor the :call instruction's function dispatch into a reusable call_value/3 public function so both the executor and pcall can share the call logic.
Tests Unblocked (5)
- lua_test.exs:283 — "it can register functions that take callbacks that modify state"
- lua_test.exs:411 — "functions that raise errors still update state"
- lua_test.exs:429 — "functions that raise errors from Elixir still update state"
- lua_test.exs:595 — "api functions can return errors"
- lua_test.exs:958 — "arithmetic exceptions are handled"
Phase 4: Type-Safe Arithmetic & Comparisons
Goal: Proper Lua 5.3 arithmetic semantics with clear error messages.
File: lib/lua/vm/executor.ex
4A. Arithmetic Type Checking
Currently arithmetic operators use raw Elixir operators, which crash on non-numbers. Wrap each in type-checking helpers:
- Check operands are numbers (or coerce strings to numbers per Lua 5.3 rules)
- Raise TypeError with "attempt to perform arithmetic on a value" on failure
4B. Division by Zero
- Integer // by zero → error
- Float / by zero → inf / -inf / nan (Lua 5.3 behavior)
- Integer % by zero → error
4C. Comparison Safety
- == and ~= work on any types (return false for different types)
- <, <=, >, >= only work on numbers or strings (same type), raise TypeError otherwise
- String comparison is lexicographic
Tests Unblocked (1)
- lua_test.exs:958 — "arithmetic exceptions are handled" (also benefits from Phase 3)
Phase 5: String Standard Library
Goal: Implement string.* functions enabling string manipulation.
5A. Create String Stdlib
File: NEW lib/lua/vm/stdlib/string.ex
Priority functions: ┌──────────────────────────────────┬──────────────────────────────────┐ │ Function │ Complexity │ ├──────────────────────────────────┼──────────────────────────────────┤ │ string.lower(s) │ Simple │ ├──────────────────────────────────┼──────────────────────────────────┤ │ string.upper(s) │ Simple │ ├──────────────────────────────────┼──────────────────────────────────┤ │ string.len(s) │ Simple │ ├──────────────────────────────────┼──────────────────────────────────┤ │ string.sub(s, i, j) │ Simple │ ├──────────────────────────────────┼──────────────────────────────────┤ │ string.rep(s, n, sep) │ Simple │ ├──────────────────────────────────┼──────────────────────────────────┤ │ string.reverse(s) │ Simple │ ├──────────────────────────────────┼──────────────────────────────────┤ │ string.byte(s, i, j) │ Simple │ ├──────────────────────────────────┼──────────────────────────────────┤ │ string.char(...) │ Simple │ ├──────────────────────────────────┼──────────────────────────────────┤ │ string.format(fmt, ...) │ Medium — C-style format strings │ ├──────────────────────────────────┼──────────────────────────────────┤ │ string.find(s, pat, init, plain) │ Medium-Hard — Lua pattern engine │ ├──────────────────────────────────┼──────────────────────────────────┤ │ string.match(s, pat, init) │ Hard — Lua pattern engine │ ├──────────────────────────────────┼──────────────────────────────────┤ │ string.gmatch(s, pat) │ Hard — returns iterator │ ├──────────────────────────────────┼──────────────────────────────────┤ │ string.gsub(s, pat, repl, n) │ Hard — Lua pattern engine │ └──────────────────────────────────┴──────────────────────────────────┘ Start with the simple functions. Implement string.format with common specifiers (%s, %d, %f, %i, %x, %%). Defer full Lua pattern matching to Phase 7 or later.
5B. Register in Stdlib
File: lib/lua/vm/stdlib.ex
Create a string table, populate with native functions, set as global.
5C. Restore Doctests
File: lib/lua.ex
Uncomment the string.lower doctests at lines 575, 586, and 667.
Tests Unblocked (3+)
- lua_test.exs:511 — "can call standard library functions"
- lua_test.exs:530 — "can call references to functions"
- lua_test.exs:539 — "it plays nicely with elixir function callbacks"
- Plus 3 #iex> doctests in lib/lua.ex
Phase 6: Math & Table Standard Libraries
Goal: Complete the core stdlib trio (string + math + table).
6A. Math Library
File: NEW lib/lua/vm/stdlib/math.ex
Functions: abs, ceil, floor, sqrt, sin, cos, tan, asin, acos, atan, exp, log, min, max, random, randomseed, huge, maxinteger, mininteger, pi, tointeger, type.
Most delegate directly to Erlang :math module.
6B. Table Library
File: NEW lib/lua/vm/stdlib/table.ex
Functions: insert, remove, concat, sort, pack, unpack, move.
These operate on {:tref, id} values through State.get_table/update_table.
6C. OS Library (minimal)
File: NEW lib/lua/vm/stdlib/os.ex
Minimal: os.clock(), os.time(), os.date(). The rest can be sandboxed.
Phase 7: Metatables & Metamethods
Goal: Core Lua OOP mechanism enabling __index, __newindex, __call, operator overloading.
7A. Stdlib: setmetatable/getmetatable
File: lib/lua/vm/stdlib.ex
Add setmetatable(table, metatable) and getmetatable(table). The Table struct already has metatable: nil.
7B. Executor: Metamethod Dispatch
File: lib/lua/vm/executor.ex
Modify these instruction handlers to check metamethods:
- get_field / get_table — if key not found, check __index (can be table or function)
- set_field / set_table — check __newindex
- Arithmetic ops — check __add, __sub, __mul, __div, __mod, __pow, __unm
- Comparisons — check __eq, __lt, __le
- :call — check __call for non-function values
- :length — check __len
- :concat — check __concat
- tostring — check __tostring
The metamethod chain: table → metatable.__index → (if table) that table's metatable.__index → ...
Tests Unblocked (1)
- lua_test.exs:809 — "method that references property" (setmetatable + __index)
Phase 8: Userdata & require/package
8A. Userdata Support
Files:
- lib/lua/vm/state.ex — add userdata: %{}, userdata_next_id: 0
- lib/lua/vm/value.ex — encode/decode {:userdata, value}
- lib/lua/util.ex — add encoded?({:usdref, _}) clause
- lib/lua/api.ex — fix is_userdata guard
8B. require/package System
Files:
- lib/lua/vm/stdlib.ex — add require function and package table
- lib/lua.ex — restore set_lua_paths/2 implementation (currently commented out at line 151)
require(modname):
- Check package.loaded[modname]
- Search package.path templates (replace ? with modname)
- Load + execute found file
- Cache in package.loaded
Tests Unblocked (6)
- lua_test.exs:1256 — "it can return userdata"
- lua_test.exs:1264 — "userdata must be encoded"
- api_test.exs:342 — "can use in functions" (guards)
- lua_test.exs:1330 — "it can find lua code when modifying package.path"
- lua_test.exs:1344 — "we can use set_lua_paths/2 to add the paths"
- lua_test.exs:1358 — "set_lua_paths/2 raises if package is sandboxed"
Phase 9: Lua 5.3 Test Suite Integration
Goal: Run the official Lua 5.3 test suite against our VM.
9A. Download Test Suite
Download from https://www.lua.org/tests/ into test/lua53_tests/.
9B. Create Lua.TestCase ExUnit Module
File: NEW test/support/lua_test_case.ex
defmodule Lua.TestCase do use ExUnit.CaseTemplate
@doc "Runs a .lua file, treating Lua assert() failures as ExUnit failures" def run_lua_file(path, opts \ []) do source = File.read!(path) lua = Lua.new() |> install_test_helpers() Lua.eval!(lua, source) end end
Install test helpers: override print for capture, ensure assert raises on failure, provide checkerr helper.
9C. Create Test Wrappers
File: NEW test/lua53_suite_test.exs
One test per .lua file. Tag by readiness:
- Ready now (after Phases 1-6): literals.lua, locals.lua, constructs.lua, bitwise.lua, vararg.lua
- Ready after Phase 5-6: math.lua, strings.lua, sort.lua
- Ready after Phase 7: events.lua (metatables), closure.lua, calls.lua
- Deferred: coroutine.lua, goto.lua, db.lua, files.lua, gc.lua, cstack.lua
9D. Iterative Gap-Filling
Run each test file, triage failures:
- Missing stdlib function → add to appropriate stdlib module
- Missing language feature → implement or tag as pending
- Semantic difference → fix VM behavior
Phase 10: Remaining TODOs & Polish
Source Code TODOs ┌──────────────────────────────────┬─────────────────────────────────┬─────────────┐ │ Location │ TODO │ Resolved By │ ├──────────────────────────────────┼─────────────────────────────────┼─────────────┤ │ lib/lua/compiler.ex:34 │ Restore compiler error handling │ Phase 2 │ ├──────────────────────────────────┼─────────────────────────────────┼─────────────┤ │ lib/lua/compiler_exception.ex:26 │ Re-add stacktrace formatting │ Phase 2 │ ├──────────────────────────────────┼─────────────────────────────────┼─────────────┤ │ lib/lua.ex:151 │ Restore set_lua_paths │ Phase 8 │ ├──────────────────────────────────┼─────────────────────────────────┼─────────────┤ │ lib/lua.ex:575,586,667 │ Restore string.lower doctests │ Phase 5 │ └──────────────────────────────────┴─────────────────────────────────┴─────────────┘ Compiler Gaps (discovered during exploration)
- Statement.LocalFunc — not compiled (catch-all returns {[], ctx})
- Statement.Do — do...end blocks not compiled
- tail_call — instruction exists but never emitted
- Multi-assignment (a, b = 1, 2) — partially supported
Deferred Features (future work)
- Coroutines (coroutine.create/resume/yield)
- goto / labels
- Full debug library
- Full io library
- Weak tables (__mode)
- Finalizers (__gc)
- Full Lua pattern matching engine (complex; start with string.find plain mode)
Phase Dependency Graph
Phase 1 (Source Lines + Call Stack) │ ▼ Phase 2 (Stack Traces + Error Messages) ──────┐ │ │ ├──────────────┐ │ ▼ ▼ │ Phase 3 Phase 4 │ (pcall) (Arithmetic Safety) │ │ │ │ ▼ ▼ │ Phase 5 (String Stdlib) │ │ │ ▼ │ Phase 6 (Math + Table Stdlib) │ │ │ ▼ │ Phase 7 (Metatables) │ │ │ ▼ │ Phase 8 (Userdata + require) │ │ │ ▼ ▼ Phase 9 (Lua 5.3 Test Suite) ◄────────────────┘ │ ▼ Phase 10 (Polish + Remaining TODOs)
Phases 3 & 4 can run in parallel. Phase 9 can start partially after Phase 2 (for basic test files) and expand as features land.
Verification (per phase)
mix compile --warnings-as-errors mix test --exclude pending # all pass, no new failures mix test # check pending count decreases mix dialyzer # 0 errors
After Phase 9: mix test --only lua53 to run the Lua 5.3 suite.