Lua (Lua v1.0.0-rc.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-rc"}
]
endQuickstart
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:
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)
# e.message is a formatted, colorized frame (ANSI codes elided here):
#
# Lua runtime error: Runtime Error
#
# at <eval>:2:
#
# runtime error: something went wrong
e.message
endLua-level error handling works too — pcall catches the error and returns it
as a value:
iex> {[false, "nope"], _lua} = Lua.eval!(~S[return pcall(function() error("nope") end)])Calling 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)
trueMetatables 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, 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 —
Luadoes not read your host filesystem. Theio.*library andrequire/dofileare sandboxed by default and raise rather than touching disk; there is no host-OS file or module resolution. - 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. This
release is 1.0.0-rc.0.
Examples
Runnable, end-to-end scripts live in
examples/. Run
any of them with mix run examples/<name>.exs:
examples/01_quickstart.exs— eval some Lua and get the result.examples/02_userdata.exs— pass an Elixir struct as userdata and call methods on it from Lua.examples/03_custom_stdlib.exs— add an Elixir-defined function to the state and call it from Lua.examples/04_sandboxing.exs— the default sandbox plus allowing specificos.*ops explicitly.examples/05_chunks.exs— compile once, eval many times.examples/06_error_handling.exs—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
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
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"`).
@spec eval!(t() | String.t() | Lua.Chunk.t(), String.t() | Lua.Chunk.t() | keyword()) :: {[term()], t()}
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
@spec parse_chunk(String.t()) :: {:ok, Lua.Chunk.t()} | {:error, [String.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 will be returned as a list of formatted strings
<!-- Old Luerl error format: Lua.parse_chunk("local foo =;") returned {:error, ["Line 1: syntax error before: ';'"]} -->
iex> {:error, [msg]} = Lua.parse_chunk("local foo =;")
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).