Current section

Files

Jump to
lua lib lua.ex
Raw

lib/lua.ex

external_resource = "README.md"
defmodule Lua do
@moduledoc external_resource
|> File.read!()
|> String.split("<!-- MDOC !-->")
|> Enum.fetch!(1)
alias Lua.Util
alias Lua.VM.AssertionError
alias Lua.VM.Display
alias Lua.VM.Executor
alias Lua.VM.InternalError
alias Lua.VM.RuntimeError
alias Lua.VM.State
alias Lua.VM.Table
alias Lua.VM.TypeError
alias Lua.VM.Value
@external_resource external_resource
@type t :: %__MODULE__{}
# Compiler.compile/1 currently always succeeds but the spec allows {:error, _}
# for future-proofing. Suppress dialyzer warnings on those defensive branches.
@dialyzer {:no_match, eval!: 2, eval!: 3, parse_chunk: 1}
defstruct [:state, debug: false]
@default_sandbox [
[:io, :stdin],
[:io, :stdout],
[:io, :stderr],
[:io, :read],
[:io, :write],
[:io, :open],
[:io, :close],
[:io, :lines],
[:io, :popen],
[:io, :tmpfile],
[:io, :output],
[:io, :input],
[:io, :flush],
[:io, :type],
[:file],
[:os, :execute],
[:os, :exit],
[:os, :getenv],
[:os, :remove],
[:os, :rename],
[:os, :tmpname],
[:package],
[:load],
[:loadfile],
[:require],
[:dofile],
[:loadstring]
]
defimpl Inspect do
def inspect(_lua, _opts) do
"#Lua<>"
end
end
@doc """
Initializes a Lua VM sandbox
iex> Lua.new()
By default, the following Lua functions are sandboxed.
#{Enum.map_join(@default_sandbox, "\n", fn func -> "* `#{inspect(func)}`" end)}
To disable, use the `sandboxed` option, passing an empty list
iex> Lua.new(sandboxed: [])
Alternatively, you can pass your own list of functions to sandbox. This is equivalent to calling
`Lua.sandbox/2`.
iex> Lua.new(sandboxed: [[:os, :exit]])
## Options
* `:sandboxed` - list of paths to be sandboxed, e.g. `sandboxed: [[:require], [:os, :exit]]`
* `:exclude` - list of paths to exclude from the sandbox, e.g. `exclude: [[:require], [:package]]`
* `:debug` - (default `false`) when `true`, internal Lua VM frames are preserved in stack traces
instead of being pruned. Useful when debugging library bugs.
* `:max_call_depth` - (default `:infinity`) caps the depth of nested function calls. When a
script recurses deeper than this, a catchable `"stack overflow"` runtime error is raised
instead of letting the recursion exhaust the host process. Accepts a positive integer or
`:infinity` for no limit.
Note: this VM does not implement proper tail-call optimization, so a call in tail position
(`return f(x)`) consumes a frame like any other call. A finite `:max_call_depth` therefore
bounds tail recursion too — including loops that PUC-Lua would run indefinitely. Leave the
default `:infinity` if you rely on unbounded tail recursion.
iex> lua = Lua.new(max_call_depth: 10)
iex> {[false, message], _lua} = Lua.eval!(lua, "local function f() return f() end return pcall(f)")
iex> message =~ "stack overflow"
true
* `:max_string_bytes` - (default 256 MiB) ceiling for any single string the VM will build,
whether via `..`, `string.rep`, or a `load` reader. An oversized result raises a catchable
`"resulting string too large"` error — the size is computed *before* allocating, so the
bomb is refused rather than detected after the fact. Accepts a positive integer or
`:infinity` for no limit (matching `:max_call_depth` and `:max_instructions`).
When running the VM inside a process capped with `:max_heap_size`, set this comfortably
below the heap cap: the default ceiling permits strings large enough to trip a smaller
heap cap mid-allocation, where the kill depends on garbage-collection timing rather than
a deterministic refusal.
iex> lua = Lua.new(max_string_bytes: 1024)
iex> {[false, message], _lua} = Lua.eval!(lua, "return pcall(string.rep, 'x', 2048)")
iex> message =~ "resulting string too large"
true
* `:max_instructions` - (default `:infinity`) caps the number of VM instructions a single
evaluation may execute. When a script exceeds this budget, a catchable
`"instruction budget exceeded"` runtime error is raised, giving library consumers a
deterministic CPU bound without wrapping each call in a host `Task` + wall-clock timeout.
Accepts a positive integer or `:infinity` for no limit. The budget is enforced at loop
back-edges and call boundaries, so the default `:infinity` path carries no per-instruction
cost. The budget is fresh per top-level evaluation and recoverable via `pcall`.
iex> lua = Lua.new(max_instructions: 1000)
iex> {[false, message], _lua} = Lua.eval!(lua, "return pcall(function() while true do end end)")
iex> message =~ "instruction budget exceeded"
true
"""
@spec new(keyword()) :: t()
def new(opts \\ []) do
opts =
Keyword.validate!(opts,
sandboxed: @default_sandbox,
exclude: [],
debug: false,
max_call_depth: :infinity,
max_string_bytes: Lua.VM.Limits.max_string_bytes(),
max_instructions: :infinity
)
exclude = Keyword.fetch!(opts, :exclude)
debug = Keyword.fetch!(opts, :debug)
max_call_depth = validate_max_call_depth!(Keyword.fetch!(opts, :max_call_depth))
max_string_bytes = validate_max_string_bytes!(Keyword.fetch!(opts, :max_string_bytes))
max_instructions = validate_max_instructions!(Keyword.fetch!(opts, :max_instructions))
state = %{
Lua.VM.Stdlib.install(State.new())
| max_call_depth: max_call_depth,
max_string_bytes: max_string_bytes,
max_instructions: max_instructions
}
opts
|> Keyword.fetch!(:sandboxed)
|> Enum.reject(fn path -> path in exclude end)
|> Enum.reduce(%__MODULE__{state: state, debug: debug}, &sandbox(&2, &1))
end
defp validate_max_call_depth!(:infinity), do: :infinity
defp validate_max_call_depth!(depth) when is_integer(depth) and depth > 0, do: depth
defp validate_max_call_depth!(other) do
raise ArgumentError,
":max_call_depth must be a positive integer or :infinity, got: #{inspect(other)}"
end
defp validate_max_string_bytes!(:infinity), do: :infinity
defp validate_max_string_bytes!(bytes) when is_integer(bytes) and bytes > 0, do: bytes
defp validate_max_string_bytes!(other) do
raise ArgumentError,
":max_string_bytes must be a positive integer or :infinity, got: #{inspect(other)}"
end
defp validate_max_instructions!(:infinity), do: :infinity
defp validate_max_instructions!(instruction_count) when is_integer(instruction_count) and instruction_count > 0,
do: instruction_count
defp validate_max_instructions!(other) do
raise ArgumentError,
":max_instructions must be a positive integer or :infinity, got: #{inspect(other)}"
end
@doc """
Write Lua code that is parsed at compile-time.
iex> ~LUA"return 2 + 2"
"return 2 + 2"
If the code cannot be lexed and parsed, it raises a `Lua.CompilerException`
#iex> ~LUA":not_lua"
** (Lua.CompilerException) Failed to compile Lua!
As an optimization, the `c` modifier can be used to return a pre-compiled Lua chunk
iex> ~LUA"return 2 + 2"c
"""
defmacro sigil_LUA(code, opts) do
code =
case code do
{:<<>>, _, [literal]} -> literal
_ -> raise "~Lua only accepts string literals, received:\n\n#{Macro.to_string(code)}"
end
chunk =
case Lua.Parser.parse(code) do
{:ok, ast} ->
proto = Lua.Compiler.compile!(ast)
%Lua.Chunk{prototype: proto}
{:error, msg} ->
raise Lua.CompilerException, msg
end
case opts do
[?c] -> Macro.escape(chunk)
_ -> code
end
end
@doc """
Sandboxes the given path, swapping out the implementation with
a function that raises when called
iex> lua = Lua.new(sandboxed: [])
iex> Lua.sandbox(lua, [:os, :exit])
"""
@spec sandbox(t(), [atom() | String.t()]) :: t()
def sandbox(lua, path) do
set!(lua, path, fn args ->
raise Lua.RuntimeException,
"#{Util.format_function(path, Enum.count(args))} is sandboxed"
end)
end
@doc """
Sets the path patterns that the VM will look in when requiring Lua scripts. For example,
if you store Lua files in your application's priv directory:
#iex> lua = Lua.new(exclude: [[:package], [:require]])
#iex> Lua.set_lua_paths(lua, ["myapp/priv/lua/?.lua", "myapp/lua/?/init.lua"])
Now you can use the [Lua require](https://www.lua.org/pil/8.1.html) function to import
these scripts
> #### Warning {: .warning}
> In order to use `Lua.set_lua_paths/2`, the following functions cannot be sandboxed:
> * `[:package]`
> * `[:require]`
>
> By default these are sandboxed, see the `:exclude` option in `Lua.new/1` to allow them.
"""
@spec set_lua_paths(t(), [String.t()] | String.t()) :: t()
def set_lua_paths(%__MODULE__{} = lua, paths) when is_list(paths) do
set_lua_paths(lua, Enum.join(paths, ";"))
end
def set_lua_paths(%__MODULE__{} = lua, paths) when is_binary(paths) do
set!(lua, ["package", "path"], paths)
end
@doc """
Sets a table value in Lua. Nested keys will allocate
intermediate tables
iex> Lua.set!(Lua.new(), [:hello], "World")
It can also set nested values
iex> Lua.set!(Lua.new(), [:a, :b, :c], [])
These table values are availble in Lua scripts
iex> lua = Lua.set!(Lua.new(), [:a, :b, :c], "nested!")
iex> {result, _} = Lua.eval!(lua, "return a.b.c")
iex> result
["nested!"]
`Lua.set!/3` can also be used to expose Elixir functions
iex> lua = Lua.set!(Lua.new(), [:sum], fn args -> [Enum.sum(args)] end)
iex> {[10], _lua} = Lua.eval!(lua, "return sum(1, 2, 3, 4)")
Functions can also take a second argument for the state of Lua
iex> lua =
...> Lua.set!(Lua.new(), [:set_count], fn args, state ->
...> {[], Lua.set!(state, :count, Enum.count(args))}
...> end)
iex> {[3], _} = Lua.eval!(lua, "set_count(1, 2, 3); return count")
"""
@spec set!(t(), atom() | String.t() | [atom() | String.t()], term()) :: t()
def set!(%__MODULE__{}, [], _) do
raise Lua.RuntimeException, "Lua.set!/3 cannot have empty keys"
end
def set!(%__MODULE__{} = lua, keys, func) when is_function(func, 1) or is_function(func, 2) do
keys = keys |> List.wrap() |> Enum.map(&to_lua_key/1)
{function_name, scope} = List.pop_at(keys, -1)
wrapped = wrap_callback(func, function_name, scope)
state = do_set_nested(lua.state, keys, wrapped)
%{lua | state: state}
end
def set!(%__MODULE__{} = lua, keys, value) do
keys = keys |> List.wrap() |> Enum.map(&to_lua_key/1)
value = Display.unwrap(value)
{function_name, scope} = List.pop_at(keys, -1)
{encoded, state} =
if Util.encoded?(value) do
{value, lua.state}
else
Value.encode(value, lua.state, &wrap_callback(&1, function_name, scope))
end
state = do_set_nested(state, keys, encoded)
%{lua | state: state}
end
# Sets a value at a nested key path, auto-allocating intermediate tables
defp do_set_nested(state, [key], value) do
State.set_global(state, key, value)
end
defp do_set_nested(state, [first | rest], value) do
# Get or allocate the table at the first key
case State.get_global(state, first) do
{:tref, _} = tref ->
state = set_in_table(state, tref, rest, value)
state
nil ->
# Allocate intermediate table and recurse
{tref, state} = State.alloc_table(state)
state = State.set_global(state, first, tref)
set_in_table(state, tref, rest, value)
_other ->
raise Lua.RuntimeException,
{:lua_error, {:illegal_index, nil, Enum.join([first | rest], ".")}, state}
end
end
# Sets a value inside nested tables, creating intermediates as needed
defp set_in_table(state, tref, [key], value) do
State.update_table(state, tref, fn table -> Table.put(table, key, value) end)
end
defp set_in_table(state, tref, [key | rest], value) do
table = State.get_table(state, tref)
case Map.get(table.data, key) do
{:tref, _} = child_tref ->
set_in_table(state, child_tref, rest, value)
nil ->
{child_tref, state} = State.alloc_table(state)
state =
State.update_table(state, tref, fn table -> Table.put(table, key, child_tref) end)
set_in_table(state, child_tref, rest, value)
_other ->
raise Lua.RuntimeException,
{:lua_error, {:illegal_index, nil, Enum.join([key | rest], ".")}, state}
end
end
@doc """
Gets a table value in Lua
iex> state = Lua.set!(Lua.new(), [:hello], "world")
iex> Lua.get!(state, [:hello])
"world"
When a value doesn't exist, it returns nil
iex> Lua.get!(Lua.new(), [:nope])
nil
It can also get nested values
iex> state = Lua.set!(Lua.new(), [:a, :b, :c], "nested")
iex> Lua.get!(state, [:a, :b, :c])
"nested"
### Options
* `:decode` - (default `true`) - By default, values are decoded
"""
@spec get!(t(), [atom() | String.t()], keyword()) :: term()
def get!(%__MODULE__{state: state}, keys, opts \\ []) when is_list(keys) do
opts = Keyword.validate!(opts, decode: true)
keys = Enum.map(keys, &to_lua_key/1)
value = do_get_nested(state, keys)
if opts[:decode] do
Value.decode(value, state)
else
value
end
end
defp do_get_nested(state, [key]) do
State.get_global(state, key)
end
defp do_get_nested(state, [first | rest]) do
case State.get_global(state, first) do
{:tref, _} = tref ->
get_in_table(state, tref, rest)
nil ->
raise Lua.RuntimeException,
{:lua_error, {:illegal_index, nil, Enum.join([first | rest], ".")}, state}
_other ->
raise Lua.RuntimeException,
{:lua_error, {:illegal_index, nil, Enum.join([first | rest], ".")}, state}
end
end
defp get_in_table(state, tref, [key]) do
table = State.get_table(state, tref)
Map.get(table.data, key)
end
defp get_in_table(state, tref, [key | rest]) do
table = State.get_table(state, tref)
case Map.get(table.data, key) do
{:tref, _} = child_tref ->
get_in_table(state, child_tref, rest)
nil ->
raise Lua.RuntimeException,
{:lua_error, {:illegal_index, nil, Enum.join([key | rest], ".")}, state}
_other ->
raise Lua.RuntimeException,
{:lua_error, {:illegal_index, nil, Enum.join([key | rest], ".")}, state}
end
end
@doc """
Evaluates the Lua script, returning any returned values and the updated
Lua environment
iex> {[42], _} = Lua.eval!(Lua.new(), "return 42")
`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
will be executing a chunk many times, but it is not necessary.
iex> {[4], _} = Lua.eval!(~LUA[return 2 + 2]c)
### Options
* `:decode` - (default `true`) By default, all values returned from Lua scripts are decoded.
This may not be desirable if you need to modify a table reference or access a function call.
Pass `decode: false` as an option to return encoded values
* `:source` - (default `"<eval>"`) Source name attached to the compiled chunk. Surfaces in
runtime errors as `at <source>:<line>:` and in stack traces. Pick a name that
identifies where this script came from (e.g. `"my_script.lua"`). When the script
is a pre-compiled `t:Lua.Chunk.t/0`, `:source` is accepted but ignored — the
chunk already carries the source name it was compiled with.
"""
@spec eval!(String.t() | Lua.Chunk.t()) :: {[term()], t()}
@spec eval!(t() | String.t() | Lua.Chunk.t(), String.t() | Lua.Chunk.t() | keyword()) ::
{[term()], t()}
@spec eval!(t(), String.t() | Lua.Chunk.t(), keyword()) :: {[term()], t()}
def eval!(script) do
eval!(new(), script, [])
end
def eval!(script, opts) when is_binary(script) or is_struct(script, Lua.Chunk) do
eval!(new(), script, opts)
end
def eval!(%__MODULE__{} = lua, script) do
eval!(lua, script, [])
end
def eval!(%__MODULE__{state: state} = lua, script, opts) when is_binary(script) do
opts = Keyword.validate!(opts, decode: true, source: "<eval>")
case Lua.Parser.parse_structured(script) do
{:ok, ast} ->
case Lua.Compiler.compile(ast, source: opts[:source]) do
{:ok, proto} ->
{:ok, results, new_state} = Lua.VM.execute(proto, state)
results =
if opts[:decode] do
Value.decode_list(results, new_state)
else
results
end
results = Display.wrap_results(results, new_state, opts[:decode])
{results, %{lua | state: new_state}}
{:error, msg} ->
raise Lua.CompilerException, msg
end
{:error, parse_errors} ->
raise Lua.CompilerException, {:parse_errors, parse_errors, script}
end
rescue
e in [Lua.RuntimeException, Lua.CompilerException] ->
reraise e, __STACKTRACE__
# Library bug, not a Lua program error — keep the full Elixir
# stack so the failing internal call site is visible.
e in [InternalError] ->
reraise Lua.RuntimeException, e, __STACKTRACE__
# Pass the VM exception itself (not just its message) so the
# `Lua.RuntimeException.exception/1` clause for arbitrary exceptions
# picks up `:line`, `:source`, and `:call_stack`.
e in [RuntimeError, TypeError, AssertionError] ->
reraise Lua.RuntimeException, e, prune_lua_internals(__STACKTRACE__, lua.debug)
e ->
reraise Lua.RuntimeException, e, prune_lua_internals(__STACKTRACE__, lua.debug)
end
def eval!(%__MODULE__{state: state} = lua, %Lua.Chunk{prototype: proto}, opts) do
# `:source` is accepted for symmetry with the string clause but ignored: a
# chunk was already compiled with its source name baked into the prototype,
# and that name wins. Pass `:source` at `load_chunk!/2`/compile time to set it.
opts = Keyword.validate!(opts, decode: true, source: nil)
{:ok, results, new_state} = Lua.VM.execute(proto, state)
results =
if opts[:decode] do
Value.decode_list(results, new_state)
else
results
end
results = Display.wrap_results(results, new_state, opts[:decode])
{results, %{lua | state: new_state}}
rescue
e in [Lua.RuntimeException, Lua.CompilerException] ->
reraise e, __STACKTRACE__
# Library bug, not a Lua program error — keep the full Elixir
# stack so the failing internal call site is visible.
e in [InternalError] ->
reraise Lua.RuntimeException, e, __STACKTRACE__
# Pass the VM exception itself (not just its message) so the
# `Lua.RuntimeException.exception/1` clause for arbitrary exceptions
# picks up `:line`, `:source`, and `:call_stack`.
e in [RuntimeError, TypeError, AssertionError] ->
reraise Lua.RuntimeException, e, prune_lua_internals(__STACKTRACE__, lua.debug)
e ->
reraise Lua.RuntimeException, e, prune_lua_internals(__STACKTRACE__, lua.debug)
end
# Internal-namespace prefixes whose frames are noise to a Lua program
# author. We match exact module prefixes (with a trailing dot) so a
# user's own `Lua.MyApp.Something` is never collateral damage. The
# top-level `Lua.` API surface (e.g. `Lua.eval!/3`) is preserved.
@internal_module_prefixes [
"Elixir.Lua.VM.",
"Elixir.Lua.Compiler.",
"Elixir.Lua.Parser.",
"Elixir.Lua.Lexer."
]
# Removes internal executor / compiler / parser / lexer frames from a
# rescued stacktrace so the user sees only frames they can act on:
# their own call site and the public `Lua.eval!` boundary.
#
# When `debug` is `true` (set via `Lua.new(debug: true)`), pruning is
# skipped so the full internal stack is visible for library debugging.
defp prune_lua_internals(stacktrace, debug) do
if debug do
stacktrace
else
Enum.reject(stacktrace, &internal_frame?/1)
end
end
defp internal_frame?({mod, _fun, _arity, _loc}) when is_atom(mod) do
internal_module?(mod)
end
defp internal_frame?({mod, _fun, _arity, _loc, _meta}) when is_atom(mod) do
internal_module?(mod)
end
defp internal_frame?(_), do: false
defp internal_module?(mod) do
mod_str = Atom.to_string(mod)
Enum.any?(@internal_module_prefixes, &String.starts_with?(mod_str, &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!`.
This function is particularly useful for checking Lua code for syntax
erorrs and warnings at runtime. If you would like to just load a chunk,
use `load_chunk!/1` instead.
iex> {:ok, %Lua.Chunk{}} = Lua.parse_chunk("local foo = 1")
Errors found during parsing are returned as a `Lua.CompilerException`. Call
`Exception.message/1` to render them, or read the formatted list off its
`:errors` field:
iex> {:error, %Lua.CompilerException{} = error} = Lua.parse_chunk("local foo =;")
iex> [msg] = error.errors
iex> msg =~ "Expected expression"
true
"""
@spec parse_chunk(String.t()) :: {:ok, Lua.Chunk.t()} | {:error, Lua.CompilerException.t()}
def parse_chunk(code) do
case Lua.Parser.parse_structured(code) do
{:ok, ast} ->
case Lua.Compiler.compile(ast) do
{:ok, proto} ->
{:ok, %Lua.Chunk{prototype: proto}}
{:error, msg} ->
{:error, Lua.CompilerException.exception(msg)}
end
{:error, parse_errors} ->
{:error, Lua.CompilerException.exception({:parse_errors, parse_errors, code})}
end
end
@doc """
Loads string or `t:Lua.Chunk.t/0` into state so that it can be
evaluated via `eval!/2`
Strings can be loaded as chunks, which are parsed and loaded
iex> {%Lua.Chunk{}, %Lua{}} = Lua.load_chunk!(Lua.new(), "return 2 + 2")
Or a pre-compiled chunk can be loaded as well. In the old Luerl-backed implementation,
loaded chunks were marked as loaded so they wouldn't be re-loaded on each `eval!/2` call.
With the new VM, chunks hold a compiled prototype and don't need a separate loading step.
iex> {%Lua.Chunk{}, %Lua{}} = Lua.load_chunk!(Lua.new(), ~LUA[return 2 + 2]c)
"""
@spec load_chunk!(t(), String.t() | Lua.Chunk.t()) :: {Lua.Chunk.t(), t()}
def load_chunk!(%__MODULE__{} = lua, code) when is_binary(code) do
case parse_chunk(code) do
{:ok, chunk} -> {chunk, lua}
{:error, %Lua.CompilerException{} = exception} -> raise exception
end
end
def load_chunk!(%__MODULE__{} = lua, %Lua.Chunk{} = chunk) do
{chunk, lua}
end
@doc """
Calls a function in Lua's state
iex> {:ok, [ret], _lua} = Lua.call_function(Lua.new(), [:string, :lower], ["HELLO ROBERT"])
iex> ret
"hello robert"
iex> lua = Lua.new()
iex> lua = Lua.set!(lua, [:double], fn [val] -> [val * 2] end)
iex> {:ok, [_ret], _lua} = Lua.call_function(lua, [:double], [5])
References to functions can also be passed
iex> {[ref], lua} = Lua.eval!(Lua.new(), "return string.lower", decode: false)
iex> {:ok, [ret], _lua} = Lua.call_function(lua, ref, ["FUNCTION REF"])
iex> ret
"function ref"
iex> {[ref], lua} = Lua.eval!(Lua.new(), "return function(x) return x end", decode: false)
iex> {:ok, [ret], _lua} = Lua.call_function(lua, ref, [42])
iex> ret
42
## Errors
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. 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, [])
iex> Exception.message(exception) =~ "boom"
true
The raised Lua value is preserved on `:value` — exactly what `pcall` hands
back inside Lua. `error(42)` carries `42`:
iex> {[ref], lua} = Lua.eval!(Lua.new(), "return function() error(42) end", decode: false)
iex> {:error, exception, _lua} = Lua.call_function(lua, ref, [])
iex> exception.value
42
"""
@spec call_function(t(), term(), [term()]) ::
{:ok, [term()], t()} | {:error, Exception.t(), t()}
def call_function(%__MODULE__{} = lua, name, args) do
finish_call(lua, resolve_and_call(lua, name, args))
end
# `call_function/3` is a programmatic protected-call boundary. Its `:error`
# payload is always the public `Lua.RuntimeException`: the internal VM
# exception is wrapped so callers pattern-match one type, read the raised Lua
# value off `:value` and the category off `:kind`, and render with
# `Exception.message/1`. In-Lua `pcall`/`xpcall` still project the raw §6.1
# value via `ProtectedCall.error_value/1` inside the VM; they never reach
# this boundary.
defp finish_call(%__MODULE__{} = lua, {:ok, results, new_state}) do
{:ok, results, %{lua | state: new_state}}
end
# Already a host-facing exception (a nested eval, or a compiler error from
# `load`) — carry it out unchanged rather than re-wrapping.
defp finish_call(%__MODULE__{} = lua, {:error, %Lua.RuntimeException{} = e, new_state}) do
{:error, e, %{lua | state: new_state}}
end
defp finish_call(%__MODULE__{} = lua, {:error, %Lua.CompilerException{} = e, new_state}) do
{:error, e, %{lua | state: new_state}}
end
defp finish_call(%__MODULE__{} = lua, {:error, e, new_state}) when is_exception(e) do
{:error, Lua.RuntimeException.exception(e), %{lua | state: new_state}}
end
# Defensive: every `resolve_and_call/3` error path yields an exception today.
# If a bare Lua value ever reaches here, wrap it as an `error()` runtime
# exception so the boundary's contract stays uniform.
defp finish_call(%__MODULE__{} = lua, {:error, reason, new_state}) do
exception = Lua.RuntimeException.exception(RuntimeError.exception(value: reason))
{:error, exception, %{lua | state: new_state}}
end
# Resolves `name`/`func` to a callable and invokes it under protection.
# Returns the raw `{:ok, results, new_state} | {:error, exception | reason,
# new_state}` (bare `Lua.VM.State`s); callers reattach the state to `lua`.
# On error the exception struct is carried out verbatim so each caller can
# project it differently (terse value vs. rich raise).
defp resolve_and_call(%__MODULE__{} = lua, %Display.Closure{ref: ref}, args) do
resolve_and_call(lua, ref, args)
end
defp resolve_and_call(%__MODULE__{} = lua, %Display.NativeFunc{ref: ref}, args) do
resolve_and_call(lua, ref, args)
end
defp resolve_and_call(%__MODULE__{state: state}, func, args) when is_tuple(func) do
do_call_function(func, args, state)
end
defp resolve_and_call(%__MODULE__{} = lua, name, args) when is_function(name) do
{ref, lua} = encode!(lua, name)
do_call_function(ref, args, lua.state)
end
defp resolve_and_call(%__MODULE__{} = lua, name, args) do
keys = name |> List.wrap() |> Enum.map(&to_lua_key/1)
func = do_get_nested(lua.state, keys)
if is_tuple(func) do
do_call_function(func, args, lua.state)
else
# Report the *name that was looked up* — not the resolved `nil` — and
# reuse the VM's `attempt to call a <type> value (<namewhat> '<name>')`
# phrasing so the programmatic boundary matches an in-Lua call.
error = Executor.call_type_error(func, call_target_hint(name), lua.state)
{:error, error, lua.state}
end
end
# Derives a `format_target_hint/1` tag from the name passed to
# `call_function/3`. A bare name (`:foo` / `"foo"` / `[:foo]`) reads as a
# global; a nested path (`[:string, :lower]`) attributes to the final field,
# mirroring how Lua names the field it failed to resolve to a function.
defp call_target_hint(name) do
case List.wrap(name) do
[] -> nil
[single] -> {:global, to_string(single)}
segments -> {:field, to_string(List.last(segments))}
end
end
defp do_call_function({:native_func, fun}, args, state) do
{results, new_state} = fun.(args, state)
{:ok, List.wrap(results), new_state}
rescue
e -> {:error, e, recover_state(e, state)}
end
defp do_call_function({:lua_closure, proto, upvalues}, args, state) do
callee_regs = Tuple.duplicate(nil, max(proto.max_registers, proto.param_count) + 64)
callee_regs =
args
|> Enum.with_index()
|> Enum.reduce(callee_regs, fn {arg, i}, regs ->
if i < proto.param_count, do: put_elem(regs, i, arg), else: regs
end)
{results, _regs, new_state} =
Executor.execute(proto.instructions, callee_regs, upvalues, proto, state)
{:ok, results, new_state}
rescue
e -> {:error, e, recover_state(e, state)}
end
defp do_call_function({:compiled_closure, _, _} = closure, args, state) do
# Compiled callees route through the dispatcher; same observable
# contract as the interpreter branch above.
{results, new_state} = Executor.call_function(closure, args, state)
{:ok, results, new_state}
rescue
e -> {:error, e, recover_state(e, state)}
end
# A resolved tuple that isn't one of the callable shapes above — e.g. a
# table or userdata reference. No name is available at this layer, so the
# error is reported by type without a name hint.
#
# This is one place the programmatic boundary does *not* mirror the in-Lua
# `:call` opcode: a table carrying a `__call` metamethod is reported as
# "attempt to call a table value" here rather than invoking the metamethod.
# `call_function/3` is a by-name function-call entry point, not a general
# value-application path, so callable tables are out of scope (and the prior
# implementation errored on them too).
defp do_call_function(other, _args, state) do
{:error, Executor.call_type_error(other, nil, state), state}
end
# This is a protected-call boundary like pcall: trapping the error
# unwinds control state, but heap effects the callee made before raising
# are kept (Lua 5.3 §2.3). VM exceptions ferry the raise-time state out
# on their `:state` field; anything else falls back to the entry state.
defp recover_state(%{state: %State{} = raised}, entry), do: State.unwind_to(entry, raised)
defp recover_state(_e, entry), do: entry
@doc """
The raising variant of `call_function/3`
This is also useful for executing Lua function's inside of Elixir APIs
```elixir
defmodule MyAPI do
use Lua.API, scope: "example"
deflua foo(value), state do
Lua.call_function!(state, [:string, :lower], [value])
end
end
```
"""
@spec call_function!(t(), term(), [term()]) :: {[term()], t()}
def call_function!(%__MODULE__{} = lua, func, args) do
# Unlike `call_function/3`, the raising variant keeps the rich
# `ErrorFormatter` render: re-raising the original VM exception through
# `Lua.RuntimeException` runs `Exception.message/1` (the terminal renderer)
# and copies its `:line` / `:source` / `:call_stack` onto the wrapper.
case resolve_and_call(lua, func, args) do
{:ok, ret, new_state} -> {ret, %{lua | state: new_state}}
{:error, e, _new_state} when is_exception(e) -> raise Lua.RuntimeException, e
{:error, reason, new_state} -> raise Lua.RuntimeException, {:lua_error, reason, new_state}
end
end
@doc """
Returns the underlying VM tag tuple for a display struct returned by
`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`.
iex> {[t], _lua} = Lua.eval!(Lua.new(), "return {1, 2, 3}", decode: false)
iex> match?({:tref, _}, Lua.unwrap(t))
true
iex> {[c], _} = Lua.eval!(Lua.new(), "return function() end")
iex> match?({:lua_closure, _, _}, Lua.unwrap(c)) or match?({:compiled_closure, _, _}, Lua.unwrap(c))
true
iex> Lua.unwrap(42)
42
Useful when you need to pass an `eval`-returned closure or table
reference to a tool that expects the raw VM tag (for example, an
internal helper or a custom `deflua`).
> #### Tag tuples are not stable API {: .warning}
> The `{:tref, _}`, `{:lua_closure, _, _}`, `{:compiled_closure, _, _}`, and
> `{:native_func, _}` shapes returned here are VM internals and may change
> between releases. Use the `Lua.API` guards (`is_table/1`, `is_lua_func/1`,
> `is_erl_func/1`, `is_userdata/1`) to classify unwrapped values rather than
> pattern-matching the tuples directly.
"""
@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
<!-- Old Luerl implementation returned specific tref IDs: {encoded, _} = Lua.encode!(Lua.new(), %{a: 1}); encoded => {:tref, 14} -->
iex> {encoded, _} = Lua.encode!(Lua.new(), %{a: 1})
iex> match?({:tref, _}, encoded)
true
"""
@spec encode!(t(), term()) :: {term(), t()}
# Elixir `nil` maps to Lua `nil`, mirroring `decode!/2` so the round trip is
# lossless. Without this clause `nil` falls into the atom head below and
# encodes to the string `"nil"`, which is truthy in Lua and silently inverts
# `if not value then ...` checks (e.g. `return nil, "not found"` patterns).
def encode!(%__MODULE__{} = lua, nil) do
{nil, lua}
end
def encode!(%__MODULE__{} = lua, value) when is_atom(value) and not is_boolean(value) do
{Atom.to_string(value), lua}
end
def encode!(%__MODULE__{state: state} = lua, value) do
value = Display.unwrap(value)
if Util.encoded?(value) do
{value, lua}
else
{encoded, state} = Value.encode(value, state, &wrap_callback(&1, :anonymous, []))
{encoded, %{lua | state: state}}
end
rescue
_e in [ArgumentError] ->
reraise Lua.RuntimeException, "Failed to encode #{inspect(value)}", __STACKTRACE__
_e in [FunctionClauseError] ->
reraise Lua.RuntimeException, "Failed to encode #{inspect(value)}", __STACKTRACE__
end
@doc """
Encodes a list of values into a list of encoded value
Useful for encoding lists of return values
iex> {[1, {:tref, _}, true], _} = Lua.encode_list!(Lua.new(), [1, %{a: 2}, true])
"""
@spec encode_list!(t(), [term()]) :: {[term()], t()}
def encode_list!(%__MODULE__{} = lua, list) when is_list(list) do
Enum.map_reduce(list, lua, &encode!(&2, &1))
end
@doc """
Decodes a Lua value from its internal form
iex> {encoded, lua} = Lua.encode!(Lua.new(), %{a: 1})
iex> Lua.decode!(lua, encoded)
[{"a", 1}]
"""
@spec decode!(t(), term()) :: term()
def decode!(%__MODULE__{state: state}, value) do
value = Display.unwrap(value)
if not Util.encoded?(value) do
raise Lua.RuntimeException, "Failed to decode #{inspect(value)}"
end
Value.decode(value, state)
rescue
_e in [ArgumentError, KeyError] ->
reraise Lua.RuntimeException, "Failed to decode #{inspect(value)}", __STACKTRACE__
end
@doc """
Decodes a list of encoded values
Useful for decoding all function arguments in a `deflua`
iex> {encoded, lua} = Lua.encode_list!(Lua.new(), [1, %{a: 2}, true])
iex> Lua.decode_list!(lua, encoded)
[1, [{"a", 2}], true]
"""
@spec decode_list!(t(), [term()]) :: [term()]
def decode_list!(%__MODULE__{} = lua, list) when is_list(list) do
Enum.map(list, &decode!(lua, &1))
end
@doc """
Loads a Lua file into the environment. Any values returned in the global
scope are thrown away.
Mimics the functionality of Lua's [dofile](https://www.lua.org/manual/5.3/manual.html#pdf-dofile)
"""
@spec load_file!(t(), String.t()) :: t()
def load_file!(%__MODULE__{} = lua, path) when is_binary(path) do
# Add .lua extension if not present
full_path =
if String.ends_with?(path, ".lua") do
path
else
path <> ".lua"
end
case File.read(full_path) do
{:ok, content} ->
{_results, lua} = eval!(lua, content)
lua
{:error, :enoent} ->
raise "Cannot load lua file, #{inspect(full_path)} does not exist"
{:error, reason} ->
raise "Cannot load lua file #{inspect(full_path)}: #{reason}"
end
end
@doc """
Inject functions written with the `deflua` macro into the Lua runtime.
See `Lua.API` for more information on writing api modules
### Options
* `:scope` - (optional) scope, overriding whatever is provided in `use Lua.API, scope: ...`
* `:data` - (optional) - data to be passed to the Lua.API.install/3 callback
"""
@spec load_api(t(), module(), keyword()) :: t()
def load_api(lua, module, opts \\ []) do
opts = Keyword.validate!(opts, [:scope, :data])
funcs = :functions |> module.__info__() |> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
scope = opts[:scope] || module.scope()
lua = ensure_scope!(lua, scope)
lua =
Enum.reduce(module.__lua_functions__(), lua, fn {name, with_state?, variadic?}, lua ->
arities = Map.get(funcs, name)
func =
if variadic? do
wrap_variadic_function(module, name, with_state?)
else
wrap_function(module, name, arities, with_state?)
end
set!(lua, List.wrap(scope) ++ [name], func)
end)
Lua.API.install(lua, module, scope, opts[:data])
end
@doc """
Puts a private value in storage for use in Elixir functions
iex> Lua.new() |> Lua.put_private(:api_key, "1234")
"""
@spec put_private(t(), term(), term()) :: t()
def put_private(%__MODULE__{state: state} = lua, key, value) do
%{lua | state: State.put_private(state, key, value)}
end
@doc """
Gets a private value in storage for use in Elixir functions
iex> lua = Lua.new() |> Lua.put_private(:api_key, "1234")
iex> Lua.get_private(lua, :api_key)
{:ok, "1234"}
"""
@spec get_private(t(), term()) :: {:ok, term()} | :error
def get_private(%__MODULE__{state: state}, key) do
{:ok, State.get_private(state, key)}
rescue
KeyError -> :error
end
@doc """
Gets a private value in storage for use in Elixir functions, raises if the key doesn't exist
iex> lua = Lua.new() |> Lua.put_private(:api_key, "1234")
iex> Lua.get_private!(lua, :api_key)
"1234"
"""
@spec get_private!(t(), term()) :: term()
def get_private!(%__MODULE__{} = lua, key) do
case get_private(lua, key) do
{:ok, value} -> value
:error -> raise "private key `#{inspect(key)}` does not exist"
end
end
@doc """
Deletes a key from private storage
iex> lua = Lua.new() |> Lua.put_private(:api_key, "1234")
iex> lua = Lua.delete_private(lua, :api_key)
iex> Lua.get_private(lua, :api_key)
:error
"""
@spec delete_private(t(), term()) :: t()
def delete_private(%__MODULE__{state: state} = lua, key) do
%{lua | state: State.delete_private(state, key)}
end
# Note: These functions are called from load_api and always go through set!/3's
# arity-2 clause, which wraps the state as %Lua{} before calling. So `lua` is
# already a %Lua{} struct — do NOT call wrap() again.
defp wrap_variadic_function(module, function_name, with_state?) do
if with_state? do
fn args, lua ->
execute_function(module, function_name, [args, lua], lua)
end
else
fn args, lua ->
execute_function(module, function_name, [args], lua)
end
end
end
# credo:disable-for-lines:25
defp wrap_function(module, function_name, arities, with_state?) do
if with_state? do
fn args, lua ->
if (length(args) + 1) in arities do
execute_function(module, function_name, args ++ [lua], lua)
else
arities = Enum.map(arities, &(&1 - 1))
raise Lua.RuntimeException,
function: function_name,
scope: module.scope(),
message: "expected #{Enum.join(arities, " or ")} arguments, got #{length(args)}"
end
end
else
fn args, lua ->
if length(args) in arities do
execute_function(module, function_name, args, lua)
else
raise Lua.RuntimeException,
function: function_name,
scope: module.scope(),
message: "expected #{Enum.join(arities, " or ")} arguments, got #{length(args)}"
end
end
end
end
defp execute_function(module, function_name, args, lua) do
case apply(module, function_name, args) do
# Table-like keyword list
[{_, _} | _rest] ->
raise Lua.RuntimeException,
function: function_name,
scope: module.scope(),
message: "keyword lists must be explicitly encoded to tables using Lua.encode!/2"
# Map
map when is_map(map) ->
raise Lua.RuntimeException,
function: function_name,
scope: module.scope(),
message: "maps must be explicitly encoded to tables using Lua.encode!/2"
{:error, reason} ->
raise RuntimeError, value: reason
{:error, reason, %Lua{} = returned_lua} ->
raise RuntimeError, value: reason, state: returned_lua.state
{data, %Lua{} = returned_lua} ->
data = List.wrap(data)
if not Util.list_encoded?(data) do
raise Lua.RuntimeException,
function: function_name,
scope: module.scope(),
message: "deflua functions must return encoded data, got #{inspect(data)}"
end
{data, returned_lua}
data ->
data = List.wrap(data)
if not Util.list_encoded?(data) do
raise Lua.RuntimeException,
function: function_name,
scope: module.scope(),
message: "deflua functions must return encoded data, got #{inspect(data)}"
end
{data, lua}
end
catch
thrown_value ->
{:error, "Value thrown during function '#{function_name}' execution: #{inspect(thrown_value)}"}
end
defp ensure_scope!(lua, []) do
lua
end
defp ensure_scope!(lua, scope) do
set!(lua, scope, %{})
end
defp wrap(state), do: %__MODULE__{state: state}
# Builds the `{:native_func, closure}` for a user-supplied Elixir callback.
#
# Both arities speak the documented `t:Lua.t/0` convention at the VM
# boundary: the raw `Lua.VM.State` handed in by the executor is wrapped as a
# `%Lua{}` for the callback, the returned `%Lua{}` is unwrapped back to its
# raw `.state`, and returns are validated as encoded. Sharing one builder
# keeps `Lua.set!/3` at a path, functions nested inside a `set!` value, and
# `Lua.encode!/2` on the same convention. `function_name`/`scope` feed only
# the error message.
defp wrap_callback(func, function_name, scope) when is_function(func, 1) do
{:native_func,
fn args, state ->
return = List.wrap(func.(args))
validate_encoded!(return, function_name, scope)
{return, state}
end}
end
defp wrap_callback(func, function_name, scope) when is_function(func, 2) do
{:native_func,
fn args, state ->
case func.(args, wrap(state)) do
{:error, reason, %__MODULE__{} = returned_lua} ->
raise RuntimeError, value: reason, state: returned_lua.state
{value, %__MODULE__{} = lua} ->
value = List.wrap(value)
validate_encoded!(value, function_name, scope)
{value, lua.state}
value ->
value = List.wrap(value)
validate_encoded!(value, function_name, scope)
{value, state}
end
end}
end
defp validate_encoded!(value, function_name, scope) do
if not Util.list_encoded?(value) do
raise Lua.RuntimeException,
function: function_name,
scope: scope,
message: "deflua functions must return encoded data, got #{inspect(value)}"
end
end
defp to_lua_key(key) when is_atom(key), do: Atom.to_string(key)
defp to_lua_key(key) when is_binary(key), do: key
defp to_lua_key(key), do: key
end