Lua (Lua v1.0.0-rc.1)

View Source

Embed 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"}
  ]
end

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:

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
end

Lua-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 sandboxed

To 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)
true

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
3

Coverage 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 accessLua does not read your host filesystem. The io.* library and require/dofile are 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 debug library.

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:

Documentation

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"
true

Capability 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

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

t()

@type t() :: %Lua{debug: term(), state: term()}

Functions

call_function(lua, func, args)

@spec call_function(t(), term(), [term()]) ::
  {:ok, [term()], t()} | {:error, term(), t()}

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

call_function!(lua, func, args)

@spec call_function!(t(), term(), [term()]) :: {[term()], t()}

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

decode!(lua, value)

@spec decode!(t(), term()) :: term()

Decodes a Lua value from its internal form

iex> {encoded, lua} = Lua.encode!(Lua.new(), %{a: 1})
iex> Lua.decode!(lua, encoded)
[{"a", 1}]

decode_list!(lua, list)

@spec decode_list!(t(), [term()]) :: [term()]

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]

delete_private(lua, key)

@spec delete_private(t(), term()) :: t()

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

encode!(lua, value)

@spec encode!(t(), term()) :: {term(), t()}

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

encode_list!(lua, list)

@spec encode_list!(t(), [term()]) :: {[term()], t()}

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])

eval!(script)

@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 - (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"`).

eval!(script, opts)

@spec eval!(t() | String.t() | Lua.Chunk.t(), String.t() | Lua.Chunk.t() | keyword()) ::
  {[term()], t()}

eval!(lua, script, opts)

@spec eval!(t(), String.t() | Lua.Chunk.t(), keyword()) :: {[term()], t()}

get!(lua, keys, opts \\ [])

@spec get!(t(), [atom() | String.t()], keyword()) :: term()

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

get_private(lua, key)

@spec get_private(t(), term()) :: {:ok, term()} | :error

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"}

get_private!(lua, key)

@spec get_private!(t(), term()) :: term()

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"

load_api(lua, module, opts \\ [])

@spec load_api(t(), module(), keyword()) :: t()

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

load_chunk!(lua, code)

@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)

load_file!(lua, path)

@spec load_file!(t(), String.t()) :: t()

Loads a Lua file into the environment. Any values returned in the global scope are thrown away.

Mimics the functionality of Lua's dofile

new(opts \\ [])

@spec new(keyword()) :: t()

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 - (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

parse_chunk(code)

@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

put_private(lua, key, value)

@spec put_private(t(), term(), term()) :: t()

Puts a private value in storage for use in Elixir functions

iex> Lua.new() |> Lua.put_private(:api_key, "1234")

sandbox(lua, path)

@spec sandbox(t(), [atom() | String.t()]) :: t()

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])

set!(lua, keys, func)

@spec set!(t(), atom() | String.t() | [atom() | String.t()], term()) :: t()

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")

set_lua_paths(lua, paths)

@spec set_lua_paths(t(), [String.t()] | String.t()) :: t()

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.

sigil_LUA(code, opts)

(macro)

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

unwrap(value)

@spec unwrap(term()) :: term()

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).