Lua (Lua v1.0.1)
View SourceEmbed a sandboxed Lua 5.3 scripting runtime in your Elixir application — no NIFs, no C, no Erlang runtime dependency.
Lua is a Lua 5.3 virtual machine implemented entirely in Elixir. The lexer,
parser, register-based VM, and standard library all run directly on the BEAM,
so there is nothing to compile and no foreign code in your release. It exists
to let you safely run untrusted scripts — AI-agent–authored code, game logic,
user-defined rules, configuration, plugins — with a small, idiomatic Elixir API
for passing data and functions across the boundary. Giving an AI agent a
sandboxed runtime where it can only call the Elixir functions you expose is a
primary use case. Scripts are sandboxed by default, errors carry source and
line information, and each Lua value is plain immutable Elixir state with no
shared mutable globals.
Installation
Add lua to your dependencies in mix.exs:
def deps do
[
{:lua, "~> 1.0.0"}
]
endUpgrading from 0.4.0 or earlier? See the
Migrating to 1.0 guide.
Quickstart
Evaluate Lua with Lua.eval!/2. It returns {results, lua} where results
is the list of returned values and lua is the updated state:
iex> {[4], _lua} = Lua.eval!("return 2 + 2")You can thread state across multiple evaluations, set globals from Elixir, and read them back:
iex> lua = Lua.set!(Lua.new(), [:name], "world")
iex> {[greeting], _lua} = Lua.eval!(lua, ~S[return "hello, " .. name])
iex> greeting
"hello, world"Tour
Error messages with source and line
Runtime errors raise Lua.RuntimeException, which carries the failing
:source and :line so you can report exactly where a script broke:
The ~LUA sigil used below (and elsewhere in this tour) needs import Lua:
import Lua
try do
Lua.eval!(~LUA"""
local x = 1
error("something went wrong")
""")
rescue
e in Lua.RuntimeException ->
e.line # => 2
e.source # => "<eval>" (chunk name)
#
# Lua runtime error: at <eval>:2:
#
# runtime error: something went wrong
Exception.message(e)
endLua-level error handling works too — pcall catches the error and returns it
as a value:
iex> {[false, err], _lua} = Lua.eval!(~S[return pcall(function() error("nope") end)])
iex> err =~ "nope"
trueCalling Elixir functions from Lua
The quickest way to expose an Elixir function is Lua.set!/3:
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)")For richer APIs, define a module with use Lua.API and the deflua macro,
then load it with Lua.load_api/2:
defmodule MyAPI do
use Lua.API
deflua double(v), do: 2 * v
end
lua = Lua.new() |> Lua.load_api(MyAPI)
{[10], _lua} = Lua.eval!(lua, "return double(5)")Userdata
Pass an arbitrary Elixir term across the boundary as a {:userdata, term}
tuple. It round-trips opaquely — Lua can hold the reference and hand it back,
but cannot inspect or dereference it:
iex> lua = Lua.set!(Lua.new(), [:thing], {:userdata, %{secret: 42}})
iex> {[{:userdata, %{secret: 42}}], _lua} = Lua.eval!(lua, "return thing")Sandboxing
Lua.new/1 sandboxes dangerous stdlib paths by default, including
os.execute, os.exit, os.getenv, file I/O (io.*), require, load, and
dofile. Calling a sandboxed function raises rather than touching the host:
Lua.eval!(~S[os.execute("rm -rf /")])
# ** (Lua.RuntimeException) Lua runtime error: os.execute(_) is sandboxedTo allow a specific operation, exclude it from the sandbox explicitly:
iex> lua = Lua.new(exclude: [[:os, :getenv]])
iex> {[value], _lua} = Lua.eval!(lua, ~S[return os.getenv("HOME")])
iex> is_binary(value)
trueResource limits
Sandboxing controls which functions a script may call, but it does not stop
a script from spinning forever or recursing without bound. Two options on
Lua.new/1 give you deterministic limits without wrapping each evaluation in a
host Task plus a wall-clock timeout. Both default to :infinity (no limit)
and raise catchable runtime errors, so pcall recovers from them in-band:
:max_call_depthcaps nested function-call depth; exceeding it raises"stack overflow".:max_instructionscaps the number of VM instructions a single evaluation may execute; exceeding it raises"instruction budget exceeded".iex> lua = Lua.new(max_instructions: 1000) iex> {[false, message], _lua} = Lua.eval!(lua, ~S[return pcall(function() while true do end end)]) iex> message =~ "instruction budget exceeded" true
See the runnable Sandboxing example, or the Security and sandboxing guide, for details.
Metatables and metamethods
Full metamethod dispatch is supported (__index, __newindex, __call,
arithmetic, comparison, length, concatenation, and __tostring), so idiomatic
Lua object patterns work as written:
iex> {[result], _lua} = Lua.eval!(~LUA"""
...> local Vec = {}
...> Vec.__index = Vec
...> Vec.__add = function(a, b) return setmetatable({x = a.x + b.x}, Vec) end
...> local a = setmetatable({x = 1}, Vec)
...> local b = setmetatable({x = 2}, Vec)
...> return (a + b).x
...> """)
iex> result
3Coverage and status
Lua targets Lua 5.3. The lexer, parser, register-based VM, value
encoding/decoding, varargs, multiple returns, _G/_ENV, metatables, the
string-pattern engine (find/match/gmatch/gsub), and the string,
table, math, os, utf8, and debug standard libraries are implemented.
As a sandboxed embedded VM, some standalone-interpreter behavior is a deliberate non-goal rather than a missing feature:
- Standalone interpreter /
os.execute— there is no shell-out to the host. - Host filesystem access is deny-by-default —
Luadoes not read your host filesystem unless you opt in. Theio.*library andrequire/dofileare sandboxed by default and raise rather than touching disk, so out of the box there is no host-OS file or module resolution. When you want it, you enable it explicitly:Lua.set_lua_paths/2configures whererequireresolves modules on disk, andLua.load_file!/2loads a Lua file from the host. - Coroutines, garbage collection / weak tables, and the full
debuglibrary.
For the live Lua 5.3 official test-suite pass count and the rationale behind
each deferral, see the
ROADMAP.md.
Examples
End-to-end, runnable Livebook notebooks live in
guides/examples/.
Open any of them in Livebook, or read them rendered under Examples on
HexDocs:
quickstart.livemd— eval some Lua and get the result.userdata.livemd— pass an Elixir struct as userdata and call methods on it from Lua.custom_stdlib.livemd— add an Elixir-defined function to the state and call it from Lua.sandboxing.livemd— the default sandbox plus allowing specificos.*ops explicitly.chunks.livemd— compile once, eval many times.error_handling.livemd—pcall, structured exception fields, source/line attribution.
Documentation
- Full API reference on HexDocs.
- The Working with Lua guide is a Livebook walkthrough of the embedding patterns.
- The
~LUAsigil and Mix tasks guide covers compile-time validation and tooling. - The Security and sandboxing guide covers the sandbox, allocation guards, recursion limits, and bounding CPU and memory.
Lua the Elixir library vs Lua the language
When referring to this library, Lua is stylized as a link. References to
Lua the language are in plaintext and not linked.
Security and sandboxing
Lua is built to run untrusted scripts. By default, Lua.new/1 installs
a sandbox that blocks the dangerous standard-library paths (io, file,
os.execute/exit/getenv, package, require, load, …), and the VM
guards against allocation-bomb denial-of-service by refusing oversized
string.rep, table.unpack/concat/move, and string concatenations
before they allocate.
# os.exit is sandboxed by default — calling it raises (catchable)
iex> {[false, message], _} = Lua.eval!(Lua.new(), "return pcall(os.exit)")
iex> message =~ "sandboxed"
trueCapability sandboxing (:sandboxed, :exclude, Lua.sandbox/2),
recursion limits (:max_call_depth), the built-in allocation guards, and
the host-level pattern for bounding CPU time and total memory are all
covered in the Security and sandboxing guide.
Compatibility and credits
Lua started as an ergonomic Elixir wrapper around Robert Virding's
Luerl project. As of 1.0.0 it is a full
Elixir-native reimplementation of the Lua 5.3 lexer, parser, and virtual
machine, with a public API designed to feel idiomatic from Elixir.
Compared to Luerl: Lua is pure Elixir with no shared mutable state (each
Lua value is plain immutable state you thread explicitly), ships richer error
messages with source and line attribution, and benchmarks competitively.
Luerl deserves credit as the prior art that made this possible — its design
informed many decisions in the new VM, and we benchmark against it.
License
Released under the Apache-2.0 license. See
LICENSE.
Summary
Functions
Calls a function in Lua's state
The raising variant of call_function/3
Decodes a Lua value from its internal form
Decodes a list of encoded values
Deletes a key from private storage
Encodes a Lua value into its internal form
Encodes a list of values into a list of encoded value
Evaluates the Lua script, returning any returned values and the updated Lua environment
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.
Gets a table value in Lua
Gets a private value in storage for use in Elixir functions
Gets a private value in storage for use in Elixir functions, raises if the key doesn't exist
Inject functions written with the deflua macro into the Lua runtime.
Loads string or Lua.Chunk.t/0 into state so that it can be
evaluated via eval!/2
Loads a Lua file into the environment. Any values returned in the global scope are thrown away.
Initializes a Lua VM sandbox
Parses a chunk of Lua code into a Lua.Chunk.t/0, which then can
be loaded via load_chunk!/2 or run via eval!.
Puts a private value in storage for use in Elixir functions
Sandboxes the given path, swapping out the implementation with a function that raises when called
Sets a table value in Lua. Nested keys will allocate intermediate tables
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
Write Lua code that is parsed at compile-time.
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.
Types
Functions
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
42Errors
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"
trueThe 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
The raising variant of call_function/3
This is also useful for executing Lua function's inside of Elixir APIs
defmodule MyAPI do
use Lua.API, scope: "example"
deflua foo(value), state do
Lua.call_function!(state, [:string, :lower], [value])
end
end
Decodes a Lua value from its internal form
iex> {encoded, lua} = Lua.encode!(Lua.new(), %{a: 1})
iex> Lua.decode!(lua, encoded)
[{"a", 1}]
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]
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
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
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 eval!(String.t() | Lua.Chunk.t()) :: {[term()], t()}
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- (defaulttrue) 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 inruntime 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!(t() | String.t() | Lua.Chunk.t(), String.t() | Lua.Chunk.t() | keyword()) :: {[term()], t()}
@spec format_exception(Lua.RuntimeException.t() | Lua.CompilerException.t()) :: String.t()
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"
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])
nilIt 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- (defaulttrue) - By default, values are decoded
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"}
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"
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 inuse Lua.API, scope: ...:data- (optional) - data to be passed to the Lua.API.install/3 callback
@spec load_chunk!(t(), String.t() | Lua.Chunk.t()) :: {Lua.Chunk.t(), t()}
Loads string or 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)
Loads a Lua file into the environment. Any values returned in the global scope are thrown away.
Mimics the functionality of Lua's dofile
Initializes a Lua VM sandbox
iex> Lua.new()By default, the following Lua functions are sandboxed.
[: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]
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- (defaultfalse) whentrue, 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:infinityfor 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_depththerefore bounds tail recursion too — including loops that PUC-Lua would run indefinitely. Leave the default:infinityif 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 aloadreader. 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:infinityfor no limit (matching:max_call_depthand: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 hostTask+ wall-clock timeout. Accepts a positive integer or:infinityfor no limit. The budget is enforced at loop back-edges and call boundaries, so the default:infinitypath carries no per-instruction cost. The budget is fresh per top-level evaluation and recoverable viapcall.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 parse_chunk(String.t()) :: {:ok, Lua.Chunk.t()} | {:error, Lua.CompilerException.t()}
Parses a chunk of Lua code into a 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
Puts a private value in storage for use in Elixir functions
iex> Lua.new() |> Lua.put_private(:api_key, "1234")
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])
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")
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 function to import these scripts
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.
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
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)
42Useful 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
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.