Compile Lua into Fast, Standalone Native Executables

clx is an ahead-of-time (AOT) Lua compiler and runtime for Linux, macOS, and Windows. It turns your Lua 5.5 scripts into fast, self-contained native binaries — no interpreter, no virtual machine, and no runtime dependencies to ship. Build once, and your program runs instantly, anywhere.

Everything you already know about Lua just works: the full language, standard libraries, and coroutines. When you need to load code at runtime, clx's optional dynamic mode will run a Lua virtual machine alongside your compiled code, giving you the best of both worlds: speed and flexibility.

Native modules are supported: write them against the clx C++ API or against the standard Lua 5.5 C API (lua.h, lauxlib.h, lualib.h) and link them with --modules. See the Modules and Lua C API sections for more information.

Multiplatform
Lua compiler
C++20
Backend
MIT
Open Source License
Lua 5.5
Compatibility

Features

Native AOT
Compile Lua directly to optimized C++20 code. No interpreter layer, no bytecode overhead.
Zero Dependencies
Normal AOT binaries are fully self-contained, with no libraries to install alongside your program.
Lua 5.5 runtime
Lua 5.5 language and the clx standard modules, ready to use out of the box.
Modern garbage collector
Generational collector tuned for short-lived data, with an incremental mode available.
Cross-Platform
Available on Linux, macOS, and Windows.
Optimizations
Optimizer using static analysis to speedup program execution.
C++20 Backend
SROA, SIMD vectorization, CPU cache friendly optimizations...
Extendable API
Build third-party modules with the clx C++ library or the standard Lua 5.5 C API.

Installation

clx is available as source code (build from any platform) and as pre-built binaries for Linux (x86_64), macOS (ARM64), and Windows (x86_64) from GitHub Releases, built automatically via CI. Linux binaries require glibc ≥ 2.39.

Build from source

Clone the repository and run the build script on macOS or Linux:

shmacOS / Linux
$ git clone https://github.com/samyeyo/clx $ cd clx $ ./build.sh install

This installs the clx compiler to /usr/local/bin, the runtime libraries (libclx.a, libclx_size.a, libclx_capi.a, libclx_capi_size.a, libclx_lua.a) to /usr/local/lib, and the headers to /usr/local/include. Run ./build.sh uninstall to remove it.

Windows

CMDWindows
> git clone https://github.com/samyeyo/clx > cd clx > build.bat install

This installs the compiler to %ProgramFiles%\clx\bin, the libraries (clx.lib, clx_size.lib, clx_capi.lib, clx_capi_size.lib) to %ProgramFiles%\clx\lib, and the headers to %ProgramFiles%\clx\include. Run build.bat uninstall to remove it.

On either platform, override the install location with -DCMAKE_INSTALL_PREFIX=<dir> when configuring.

Pre-built binaries

The archives from GitHub Releases ship the same layout as a source install, inside a clx-<platform>/ folder (bin/, include/, lib/), so you can extract them directly into the install prefix of your choice:

shPre-built archive (Linux / macOS)
$ tar xzf clx-linux-x86_64.tar.gz $ sudo cp -r clx-linux-x86_64/* /usr/local/ $ clx --version

After this, clx is on your PATH with the libraries and headers under /usr/local.

Target architecture (CLX_ARCH)

By default clx targets the widest compatibility baseline: sse2 on x86_64 (-msse2 / /arch:SSE2) and native on ARM64 (-mcpu=native). The same flag is baked into the runtime libraries and injected into every binary clx compiles (including --debug builds), unless you override it with an explicit compiler flag.

Override with an environment variable before the wrapper script (any CLX_* CMake option works the same way):

shLinux / macOS — x86 / ARM
# x86: sse2 (default) | avx | avx2 | native $ CLX_ARCH=avx2 ./build.sh $ CLX_ARCH=avx2 ./build.sh install # ARM: native (default) | generic (portable, no -mcpu flag) $ CLX_ARCH=generic ./build.sh $ CLX_ARCH=generic ./build.sh install # Optimize for the build machine $ CLX_ARCH=native ./build.sh
batWindows
> set CLX_ARCH=avx2 && build.bat > set CLX_ARCH=avx2 && build.bat install

When invoking cmake directly, use -D instead: cmake -S . -B build -DCLX_ARCH=avx2.

CLX_ARCH x86 flag (Clang/GCC) x86 flag (MSVC) ARM flag
sse2 (x86 default)-msse2/arch:SSE2—
avx-mavx/arch:AVX—
avx2-mavx2/arch:AVX2—
native-march=native/arch:AVX2-mcpu=native (ARM default)
generic——no flag, portable

If you pass an explicit arch flag to clx itself, it takes precedence and the CLX_ARCH default is not added: clx file.lua -march=native.

Verify installation

shVerify
$ clx --version clx 0.4.0 MIT License - Copyright (c) 2026 Tine Samir

Benchmarks

Performance comparison against Lua 5.5 and LuaJIT. The speedup is relative to Lua 5.5. Results are averages from 10 runs using hyperfine (Linux / macOS) or benchmarks\run.bat (Windows) on a single CPU; they vary with the C++ toolchain and environment.

Runtime Time Speedup

Getting Started

Build clx

Clone the repository and build from source:

shBuild
$ git clone https://github.com/samyeyo/clx $ cd clx $ ./build.sh

Add a flag to vary the build: ./build.sh debug for a debug build, ./build.sh install to install, or ./build.sh clean to clear the build directory.

Alternatively, build manually with CMake:

$ mkdir build $ cd build $ cmake .. -DCMAKE_BUILD_TYPE=Release $ cmake --build .

Compile Your First Program

Create hello.lua:

luahello.lua
print("Hello, World!")

Compile and run:

$ ./build/clx hello.lua $ ./hello Hello, World!

Your Second Program

Let's try something more interesting:

luafib.lua
-- fib.lua function fib(n) if n <= 1 then return n end return fib(n - 1) + fib(n - 2) end print("Fibonacci(20) = " .. fib(20))

Compile it with --fast flag for better performances:

$ ./build/clx --fast fib.lua $ ./fib Fibonacci(20) = 6765

Language Features

clx supports most Lua 5.5 features including variables, control flow, functions, tables, metatables, coroutines, standard libraries, and bitwise operations. load(), loadfile(), and dofile() are registered only for programs compiled with --dynamic; they execute source in the embedded Lua VM and are absent from ordinary AOT and --minimal builds. require() of Lua file modules through package.path also requires --dynamic: those files are compiled and executed on the embedded VM rather than AOT-compiled. The AOT runtime does not provide string.dump() or debug directly; the dynamic VM provides its own library behavior.

Variables and Types

lua
-- Numbers local x = 42 local pi = 3.14159 -- Strings local greeting = "Hello" local name = 'World' -- Booleans local flag = true -- Tables local t = { a = 1, b = 2 } local arr = { 1, 2, 3 } -- Functions local function add(a, b) return a + b end -- Closures local function counter() local n = 0 return function() n = n + 1 return n end end

Control Flow

lua
-- If/else if x > 10 then print("big") elseif x > 5 then print("medium") else print("small") end -- While loop while x > 0 do print(x) x = x - 1 end -- For loop (numeric) for i = 1, 10 do print(i) end -- For loop (generic) for k, v in pairs(t) do print(k, v) end -- Repeat/until repeat x = x - 1 until x == 0

Functions

lua
-- Basic function function greet(name) return "Hello, " .. name end -- Multiple return values function divmod(a, b) return math.floor(a / b), a % b end -- Variadic function sum(...) local total = 0 for i = 1, select("#", ...) do total = total + select(i, ...) end return total end -- Method syntax local obj = { value = 10 } function obj:double() self.value = self.value * 2 end

Tables and Metatables

lua
-- Table with methods local vector = { x = 0, y = 0, add = function(self, other) return { x = self.x + other.x, y = self.y + other.y } end, __tostring = function(self) return "(" .. self.x .. "," .. self.y .. ")" end } -- Metatable for operator overloading setmetatable(vector, { __add = function(a, b) return a:add(b) end }) local v1 = { x = 1, y = 2 } local v2 = { x = 3, y = 4 } local v3 = v1 + v2 -- Uses __add

Coroutines

lua
-- Producer/consumer with coroutines local function producer(max) for i = 1, max do coroutine.yield(i) end end local function consumer() local co = coroutine.create(producer) while true do local status, value = coroutine.resume(co) if not status or value == nil then break end print("Received: " .. value) end end consumer()

String Module

lua
-- Basic operations local s = "Hello, World!" print(string.len(s)) -- 13 print(string.sub(s, 1, 5)) -- Hello print(string.upper(s)) -- HELLO, WORLD! print(string.lower(s)) -- hello, world! print(string.reverse(s)) -- !dlroW ,olleH -- Character conversion print(string.byte("A")) -- 65 print(string.char(65, 66, 67)) -- ABC -- Repetition print(string.rep("ab", 3)) -- ababab print(string.rep("x", 5, "-")) -- x-x-x-x-x -- Format print(string.format("Pi: %.2f", 3.14159)) -- Pi: 3.14 -- Pattern matching local start, finish = string.find("hello world", "world") print(start, finish) -- 7 11 local match = string.match("hello world", "(%a+)") print(match) -- hello for word in string.gmatch("hello world from lua", "%a+") do print(word) end local result, count = string.gsub("hello world", "world", "lua") print(result, count) -- hello lua, 1

Bitwise Operations

lua
-- Bitwise AND, OR, XOR print(0xFF & 0x0F) -- 15 print(0xF0 | 0x0F) -- 255 print(0xFF ~ 0xF0) -- 15 -- Bitwise shifts print(1 << 8) -- 256 print(256 >> 4) -- 16 -- Bitwise NOT print(~0) -- -1

Performance Tips

Use local variables, prefer numeric for loops, and avoid mixing types for optimal performance.

luaTips
-- Good: local variables are faster local function compute() local result = 0 for i = 1, 1000 do local temp = i * 2 result = result + temp end return result end -- Prefer numeric for loops for i = 1, 1000000 do -- body end -- Avoid mixed types: 1 + "2" is slower than 1 + 2

Common Issues

Debugging Compilation Errors

If you get a C++ compilation error, you can see the generated code with --cpp:

$ clx script.lua --cpp

This creates script.cpp in the current directory, which you can examine to see what's being generated.

Understanding Runtime Errors

Runtime errors show the Lua line where the error occurred:

Error: script.lua:10: attempt to perform arithmetic on a number value

The format is filename:line: message.

Next Steps

Read the Dynamic execution, CLI, Modules, and Compatibility documentation.

Dynamic execution

What is it?

Usually clx compiles your Lua ahead of time into a native program. Sometimes you also want to run Lua source at runtime — for example a user-supplied script, a config file, or a plugin. The optional --dynamic switch enables this by embedding a Lua 5.5 engine.

shBuild and compile
$ cmake -S . -B build $ cmake --build build $ ./build/clx main.lua --dynamic --output myapp $ ./myapp

Don't combine --dynamic with --minimal if you need runtime loading — minimal builds leave out the library setup that enables it.

Runtime loading

A --dynamic build adds three familiar functions. load compiles a string of source and returns a callable function, loadfile does the same for a file, and dofile loads and immediately runs a file:

local chunk, err = load(source) local chunk, err = loadfile(filename) local result = dofile(filename)
luaExample
local chunk, err = load("return 6 * 7", "smoke", "t") assert(chunk, err) assert(chunk() == 42)

Always check the first result before calling the chunk, especially if the source comes from an untrusted user.

Requiring modules at runtime

A --dynamic build also completes require(): package.searchers[2] finds Lua files via package.path and runs them on the embedded VM — the same way loadfile does. Required modules are not AOT-compiled, and the value they return crosses the boundary back to your compiled code:

luaExample
package.path = package.path .. ";./lib/?.lua;./lib/?/init.lua" local greet = require("greet") -- ./lib/greet.lua, executed on the embedded VM print(greet.hello("world"))

Modules bundled at compile time or registered in package.preload are found by the preload searcher first and never touch the VM. In plain AOT builds the file searcher locates the file but reports that --dynamic is required.

Things to keep in mind

  • Your compiled code is usually faster. Dynamic calls cross between the two environments with some overhead, so keep performance-critical loops out of loaded code.
  • Libraries are self-contained. Loaded code uses its own copy of the standard libraries; a compiled module isn't automatically visible to it through require.
  • Values are converted, not shared. A table passed across the boundary is a copy or a proxy — don't rely on shared identity or metatables.
  • Coroutines stay on one side. Keep create/resume/yield inside either the compiled code or the loaded chunk, not across the boundary.

Sharing values with loaded code

A loaded chunk can read your program's globals. Here your program sets a global that a loaded chunk then reads back:

shared_value = 123 local chunk = load("return shared_value", "g", "t") assert(chunk() == 123)

You can also give a chunk its own environment table instead of using your globals.

Limitations

  • Requires compiling with --dynamic; skipped in --minimal builds.
  • load accepts a source string only (no reader-function form).
  • string.dump and loading dumped bytecode aren't provided by the compiled runtime.
  • Tables, metatables, userdata, and coroutines don't share identity across the two environments.

For full details, see doc/dynamic-lua.md.

CLI Reference

Usage

$ clx [options] <file.lua> [<compiler-options>]

Options starting with - that are not recognized by clx are automatically passed through to the C++ compiler.

Build Mode

--executable Compile to executable (default) --object Compile to object file (.o/.obj) --static Compile to static module (.a/.lib)

Output Options

--output <name> Specify output file name

Compilation Options

--debug Enable debug symbols, disable optimizations; #line directives map debugger views to Lua source --size Optimize for size (default) --fast Optimize for speed --cpp Generate C++ source files, don't compile --minimal Exclude non-essential modules (table, io, os, math, utf8, coroutine); keeps base + package + string --dynamic Link the embedded Lua 5.5 VM and enable load, loadfile, and dofile --modules <list> Precompiled C++/C modules to link (comma-separated)

Choosing speed vs. size

The two common build modes boil down to a simple tradeoff:

--size (default) Smallest possible binary — best for scripts and installers --fast Fastest execution — best for heavy math or computation

For most ordinary programs the difference is small. Pick --fast when your program is dominated by computation, and --size when binary size matters more.

If you pass your own compiler options (for example -O2 or -march=native), clx uses exactly those and skips its default flags.

Platform-Specific

The C++ compiler is fixed when clx is built (the same compiler that built clx compiles your Lua scripts), which keeps toolchains consistent.

  • Linux/macOS produce an executable with no extension, an object as .o, and a static library as .a.
  • Windows produces .exe, .obj, and .lib.

Examples

Compile to an executable (the default):

$ clx script.lua

Give the output a custom name:

$ clx script.lua --output myapp

Build for the fastest execution (or use --size, the default, for the smallest binary):

$ clx script.lua --fast

Build a debuggable program so you can step through the Lua source in a debugger:

$ clx script.lua --debug

Write out the generated C++ without compiling (useful when debugging clx itself):

$ clx script.lua --cpp

Pass your own compiler flags (this replaces clx's default flags):

$ clx script.lua -O2

Produce an object file or a static library instead of an executable:

$ clx script.lua --object $ clx script.lua --static

Environment Variables

clx respects these environment variables:

CXX (Not read — compiler fixed at build time)

Exit Codes

0 Success 1 Usage or compilation error

Build with CMake

If building from source:

$ mkdir build $ cd build $ cmake .. -DCMAKE_BUILD_TYPE=Release $ cmake --build . $ ./clx --help

Modules

clx supports four ways to organize and load modules: Lua source modules compiled alongside your entry point (static preload), statically linked native modules — C++ or C — with --modules, and — with --dynamic — Lua files loaded at runtime through package.path, which execute on the embedded Lua 5.5 VM. All four are consumed via Lua's require().

A native module is either a C++ module built against the clx C++ API, or a C module built against the standard Lua 5.5 C API (lua.h, lauxlib.h, lualib.h). clx classifies each archive by scanning its symbols, so both kinds link with the same --modules argument.

Lua Source Modules

Pass multiple .lua files to clx — the first is the entry point, the rest become modules loadable via require:

$ clx main.lua mymodule.lua utils.lua --output myapp

Inside main.lua, require them by name (filename without .lua):

luamain.lua
local mymodule = require("mymodule") local utils = require("utils") mymodule.say_hello() utils.help()

How it works

clx compiles each .lua file into a function luaopen_<module>, and your generated main() registers each module so it becomes available to require:

main() { open(); openlibs(L); register_module("mymodule", luaopen_mymodule); register_module("utils", luaopen_utils); luaopen_main(L); close(L); }

When your code calls require("mymodule"), clx checks whether the module was already loaded, and if not, runs its luaopen_ function once and caches the result. Later calls return the cached value without re-running it.

Linking

All builds link statically against libclx.a. No shared library is needed at runtime.

Module convention

A Lua source module should return a table (or any value) that becomes the result of require:

luamymodule.lua
local M = {} function M.say_hello() print("hello from mymodule") end return M

Runtime-Loaded Lua Modules (package.path)

With --dynamic, require() can also load Lua files at runtime through package.path, using the same searcher chain as stock Lua (package.searchers: preload → Lua file → C). Required files are compiled and executed on the embedded Lua 5.5 VM — they are not AOT-compiled, so keep hot loops in your bundled modules:

shBuild
$ clx main.lua --dynamic --output myapp
luamain.lua
package.path = package.path .. ";./lib/?.lua;./lib/?/init.lua" local greet = require("greet") -- ./lib/greet.lua, runs on the embedded VM local util = require("util") -- ./lib/util/init.lua

In plain AOT builds, package.path, package.searchers, and package.searchpath exist, but require of a file module reports that --dynamic is required. See Dynamic execution.

C++ Native Modules (Statically Linked)

You can link precompiled C++ code using --modules:

$ clx main.lua --modules my_native_mod

The function must use CLX_API (which provides extern linkage and proper symbol visibility):

CLX_API clx::LValue luaopen_my_native_mod(clx::LState* L);

The generated main() calls register_module, which stores the wrapper in package.preload — the function runs only on first require().

Writing a C++ native module

cppmy_native_mod.cpp
#include <clx.h> CLX_API clx::LValue luaopen_my_native_mod(clx::LState* L) { clx::LValue t = L->create_table(); clx::LTable* mod = static_cast<clx::LTable*>(t.as_pointer()); mod->bind(L, "add", [](clx::LState* L, const clx::LValue* args, size_t n) -> clx::MultiValue { double a = args[0].as_number(); double b = args[1].as_number(); return clx::MultiValue(clx::LValue(a + b)); }); return t; }

Compile it to an object file with your C++ compiler:

Linux/macOSg++/clang++
$ g++ -c -std=c++20 -I./include my_native_mod.cpp -o my_native_mod.o
WindowsMSVC
> cl /c /std:c++20 /I.\include my_native_mod.cpp /Fomy_native_mod.obj

Then link with your Lua script. clx looks for my_native_mod.a (or .lib on Windows) in the current directory, then in <clx-install-dir>/lib/clx/, and on POSIX also in /usr/local/lib/clx/:

$ clx main.lua --modules my_native_mod

If your module depends on external libraries, pass link flags directly:

$ clx main.lua --modules my_native_mod -lm -lz

Native C Modules (Standard Lua 5.5 C API)

clx ships the standard Lua 5.5 C API headers — lua.h, lauxlib.h and lualib.h — plus a bridge that implements that API on top of the clx runtime, so existing C modules written against the official API compile and run unchanged:

shBuild and link
# fetch a module such as luafilesystem, then: $ cc -c -O2 -I/path/to/clx/include lfs.c -o lfs.o $ ar rcs lfs.a lfs.o $ clx main.lua --modules lfs -o main $ ./main

The --modules argument is the archive filename without its extension, and the module must export a C luaopen_<name> symbol:

cDeclaration
#include "lua.h" #include "lauxlib.h" #include "lualib.h" int luaopen_lfs(lua_State *L); /* declared by lfs.h */

clx classifies each archive before code generation by scanning its symbols:

  • An Itanium/MSVC-mangled luaopen_<name> is treated as a C++ module and declared as clx::LValue luaopen_<name>(clx::LState*).
  • A plain, unmangled luaopen_<name> is treated as a C module; clx emits extern "C" int luaopen_<name>(struct lua_State*) and a small adapter that runs it and seeds package.loaded for require.
  • clx_luaopen_<name> (the prefixed form some wrappers emit) is also treated as a C module, declared under that symbol.

Names are resolved in the current directory first, then in clx's own lib/clx install locations and any -L flags you pass. An input .lua file whose stem collides with a precompiled C module name is rejected.

Two things to know when writing C modules:

  • luaL_checkoption returns the index of the chosen option in lst, not the string (this is a Lua 5.5 change).
  • luaL_openlibs and luaL_openselectedlibs live in lualib.h, not lauxlib.h — include it if you call them.

How to write a module against this API, what the bridge implements, and where it differs from stock Lua are covered in the Lua C API section.

Linking: the C API wrapper is its own archive

The C API bridge is not part of libclx.a / libclx_size.a. It is built into a dedicated archive installed alongside them:

ArchiveContents
libclx.a / clx.libcore runtime — values, tables, GC, stdlibs, coroutines
libclx_size.a / clx_size.libthe same core runtime, -Os (default)
libclx_capi.a / clx_capi.libLua 5.5 C API wrapper — lua_*, luaL_*, luaopen_* adapters
libclx_capi_size.a / clx_capi_size.libthe same wrapper, -Os (default)

clx links libclx_capi*.a only when --modules pulls in an archive that classifies as a C module. C++ modules and plain Lua programs never pull it in, so they pay nothing for the wrapper.

The core archive and the wrapper always come as a matched pair — pick the variant once with --size (default) or --fast:

shMatched pairs
$ clx main.lua --modules lfs -o main # libclx_size.a + libclx_capi_size.a $ clx main.lua --modules lfs --fast -o main # libclx.a + libclx_capi.a

Both archives are resolved together, in the same places, in order: build/, lib/ and lib64/ next to the clx binary, then the install prefix's libdir (the one used at cmake --install time). If the wrapper archive cannot be found the link stops with a "library not found" error; if it is found but stale, the link fails with undefined lua_* or clx::luaapi_c_module_open symbols.

If you link by hand instead of going through the driver:

shManual link
# default (--size) build $ c++ main.o lfs.a -L/path/to/clx/lib -lclx_size -lclx_capi_size -o main # --fast build $ c++ main.o lfs.a -L/path/to/clx/lib -lclx -lclx_capi -o main

Keep the pair consistent: -Os and -O3 objects are interchangeable at link time, but only one core/wrapper variant should be pulled into a given binary.

Compiling Lua to Libraries

Static Library

$ clx mylib.lua --static --output mylib

This produces libmylib.a on Linux/macOS or mylib.lib on Windows.

Object File

$ clx mylib.lua --object --output mylib

This produces mylib.o on Linux/macOS or mylib.obj on Windows.

All export luaopen_mylib. A host C++ program links against the static library:

cpphost.cpp
#include <clx.h> CLX_API clx::LValue luaopen_mylib(clx::LState* L); int main() { clx::LState* L = clx::open(); clx::openlibs(L); L->register_module("mylib", luaopen_mylib); clx::close(L); return 0; }

Combining All Approaches

$ clx main.lua utils.lua --modules native_processor --output app
luamain.lua
local utils = require("utils") local proc = require("native_processor") local extra = require("extra_plugin")

All AOT modules in this build are registered in clx's package.preload and loaded via the AOT require. This does not populate the embedded VM's package registry.

Options Reference

--modules <list> Comma-separated list of precompiled C++/C modules --fast Link libclx.a + libclx_capi.a (optimize for speed) --size Link libclx_size.a + libclx_capi_size.a (default) --minimal Exclude non-essential modules (table, io, os, math, utf8, coroutine); keeps base + package + string --dynamic Link the embedded Lua 5.5 VM and enable load, loadfile, and dofile --static Compile to static library (exports luaopen_*) --object Compile to object file (exports luaopen_*)

Writing native modules

A native module is a C or C++ file that exports one luaopen_<name> function returning a table, then gets linked with --modules. Write it against the clx C++ API (see doc/modules.md) or against the standard Lua 5.5 C API — see the Lua C API section for the worked example, what the bridge implements, and how it differs from stock Lua.

Clx C++ API

If you want to extend clx with native C++ code — for example a high-performance library module — this page shows you the API. Everything lives in namespace clx and starts with #include <clx.h>.

One thing sets clx apart from the classic Lua C API: there is no stack. You work directly with LValue objects, which is closer to normal C++. That makes the API smaller and less error-prone. If you would rather keep an existing C module written against the standard API, see Lua C API.

Setting up and tearing down

cppOpen and close
clx::LState* L = clx::open(); // create a runtime clx::openlibs(L); // load the standard libraries // ... your code ... clx::close(L); // clean up

L owns all the memory (strings, tables, threads). Call close() exactly once when you are done.

Values: LValue

An LValue can hold any Lua value: nil, boolean, number, string, table, function, userdata, or thread. You create values with factory functions rather than pushing them onto a stack:

FunctionGives you
nil()nil
boolean(bool)a boolean
number(double)a floating-point number
integer(int64_t)an integer
string(L, s)a string (short strings are stored in place, no allocation)
table(L)a table
cfunction(L, func)a function
lightuserdata(void*)an opaque pointer

Read values back with the as_* helpers:

cppReading a value
double n = v.as_number(); // as a number int64_t i = v.as_integer(); // as an integer const char* s = v.as_string(); // as a string

Quick type checking

cppType predicates
clx::is_number(v); clx::is_string(v); clx::is_table(v); clx::is_function(v); clx::is_integer(v); clx::is_nil(v); clx::is_bool(v); clx::is_userdata(v); clx::type_name(v); // "number", "string", ...

Conversions

Two families help you read arguments safely.

to_* — lenient. Use a default when the value is not what you expect:

cppLenient
double d = clx::to_number(v, 0.0); // v, or 0.0 if v isn't a number

check_* — strict. Throw a LRuntimeException on mismatch:

cppStrict
double d = clx::check_number(L, args[0]); const char* s = clx::check_string(L, args[1]);

opt_* — treat nil as the default, otherwise throw on mismatch:

cppOptional
double d = clx::opt_number(L, args[0], 1.0);

Globals

cppReading and writing globals
clx::LValue g = clx::get_global(L, "name"); clx::set_global(L, "name", val); clx::set_global(L, "price", 3.14); // numbers auto-wrapped clx::set_global(L, "count", int64_t(42)); // integers clx::set_global(L, "greeting", "hi"); // strings auto-interned

Tables

Read and write with the get_field / set_field helpers (which respect __index / __newindex), or use raw_get / raw_set to bypass metatables:

cppField access
clx::LValue v = clx::get_field(L, t, "key"); // t["key"], with __index clx::set_field(L, t, "key", val); // t["key"] = val clx::LValue v = clx::raw_get(L, t, 7); // raw access, any key type

Iterate a table with a C++ range loop:

cppIteration
for (auto it = clx::iterate(L, t); it; ++it) { auto [key, value] = *it; // use key and value }

Length and concatenation:

cpplen and concat
clx::len(L, v); // # operator clx::concat(L, a, b); // string concatenation

Calling functions

cppcall throws on error
clx::LValue f = clx::get_global(L, "myfunc"); clx::LValue args[] = { clx::number(1.0), clx::number(2.0) }; clx::MultiValue r = clx::call(L, f, args, 2); // throws on error

Or use pcall to catch errors instead of throwing:

cpppcall returns a status
clx::MultiValue r = clx::pcall(L, f, args, 2); // {true, ...} or {false, err}

A variadic form accepts native C++ values directly:

cppVariadic call
clx::MultiValue r = clx::call(L, f, clx::number(1), "hello", 3.14);

Coroutines

cppThreads
clx::LValue t = clx::create_thread(L, func); clx::MultiValue r = clx::resume(L, t, args, 1); // resume a coroutine clx::MultiValue r = clx::yield(args, 1); // yield from a coroutine

resume returns {true, ...results} or {false, error_message}.

Errors

Throw errors as exceptions:

cppRaising an error
clx::error(L, "something went wrong"); // or throw clx::LRuntimeException(clx::string(L, "oops"));

There are helpers for common argument errors:

cppArgument errors
clx::arg_error(L, 1, "number"); // "bad argument #1 (number expected)" clx::type_error(L, 1, "number"); // "bad argument #1 (number expected, got X)"

Writing a module

A native module is a C++ source file that exports one function (luaopen_<name>) which returns a table. See Modules for the full worked example and how to compile and link it.

A complete example using lazy registration:

cppmylib.cpp
#include <clx.h> static clx::MultiValue add(clx::LState* L, const clx::LValue* args, size_t n) { double sum = 0; for (size_t i = 0; i < n; i++) sum += clx::check_number(L, args[i]); return {clx::number(sum)}; } static constexpr clx::LazyReg my_funcs[] = { {"add", add}, }; CLX_API clx::LValue luaopen_mylib(clx::LState* L) { clx::LValue t = clx::table(L); clx::set_lazy_funcs(L, t, my_funcs, 1); clx::set_global(L, "mylib", t); return clx::LValue(); }

Compile this into an object or library, then link it with:

shLink
$ clx main.lua --modules mylib

Your module then becomes available as require("mylib").

Porting an existing Lua C module?

The migration guide (doc/migration-guide.md) walks you through converting a module written against the classic Lua C API. If you would rather keep it as C, link it with --modules as described in Lua C API.

The Lua C API

clx ships the standard Lua 5.5 headers — lua.h, lauxlib.h and lualib.h — plus a bridge that implements them on top of the clx runtime. C modules written against the official API compile and run unchanged: no source port, no Lua library to link against.

This page is for people using the C API from C or C++. It covers how to write a module, what the bridge implements, and where it behaves differently from stock Lua. For compiling, finding and linking archives, see Modules. If you would rather write native code in C++, see the C++ API section.

A first module

cdemo.c
#include "lua.h" #include "lauxlib.h" #include "lualib.h" static int l_add(lua_State *L) { lua_Integer a = luaL_checkinteger(L, 1); lua_Integer b = luaL_checkinteger(L, 2); lua_pushinteger(L, a + b); return 1; } int luaopen_demo(lua_State *L) { static const luaL_Reg funcs[] = { {"add", l_add}, {NULL, NULL} }; luaL_newlib(L, funcs); return 1; }
luamain.lua
local demo = require("demo") assert(demo.add(2, 3) == 5)
shBuild, link, run
$ cc -c -O2 -I/path/to/clx/include demo.c -o demo.o $ ar rcs demo.a demo.o $ clx main.lua --modules demo -o main $ ./main

Three things follow from how clx links modules:

  • The archive must be named after the module — demo.a (demo.lib on Windows) — and export a plain extern "C" int luaopen_demo(lua_State*).
  • Compile the module as C, or wrap its opener in extern "C" if you compile it as C++. clx decides how to call the opener by scanning the archive's symbols: a mangled luaopen_demo is treated as a C++ module and expected to have the signature clx::LValue luaopen_demo(clx::LState*), which a stock Lua module does not have.
  • The opener runs once, eagerly, when the program starts — before your entry chunk runs. require("demo") then returns the value already cached in package.loaded.

The headers work from C99 onwards and from C++. Put clx's include/ directory on your C compiler's include path and do not link -llua — the lua_*/luaL_* symbols come from clx's own wrapper archive (see Linking).

The state

There is exactly one lua_State per program, created by clx::open() and destroyed by clx::close().

Stock Luaclx
lua_newstate(alloc, ud, seed)returns NULL — you cannot create a state
luaL_newstate()returns NULL
lua_close(L)does nothing; the runtime owns the lifetime

Everything else about the state is real. The registry, the global table and package.loaded / package.preload are the same tables the compiled Lua code uses:

  • lua_pushglobaltable(L) gives you _G;
  • registry[LUA_RIDX_GLOBALS] is _G, registry[LUA_RIDX_MAINTHREAD] is the main thread;
  • registry["_LOADED"] is package.loaded and registry["_PRELOAD"] is package.preload, so luaL_requiref() and Lua's require() see each other's entries;
  • lua_getextraspace(L) works — clx reserves LUA_EXTRASPACE bytes in front of every lua_State.

What is implemented

Every function in lua.h, lauxlib.h and lualib.h is declared and has a definition (147 API names in total). The list below is by area; the Not implemented section names every stub explicitly.

AreaStatus
Stacklua_gettop, lua_settop, lua_pushvalue, lua_rotate, lua_copy, lua_absindex, lua_pop, lua_insert, lua_remove, lua_replaceSupported
lua_checkstackAlways returns 1 — see Other stubs
Types and conversionlua_type, lua_typename, lua_is*, lua_tonumberx, lua_tointegerx, lua_toboolean, lua_tolstring, lua_rawlen, lua_tocfunction, lua_touserdata, lua_tothread, lua_topointerSupported
Pushinglua_pushnil, lua_pushnumber, lua_pushinteger, lua_pushlstring, lua_pushstring, lua_pushfstring, lua_pushvfstring, lua_pushboolean, lua_pushlightuserdata, lua_pushcclosure, lua_pushthread, lua_pushexternalstringSupported
Tableslua_createtable, lua_newtable, lua_get{table,field,i,global}, lua_set{table,field,i,global}, lua_raw{get,geti,getp,set,seti,setp}, lua_getmetatable, lua_setmetatable, lua_next, lua_lenSupported
Userdatalua_newuserdatauv, lua_getiuservalue, lua_setiuservalueSupported
Arithmetic and comparisonlua_arith, lua_compare, lua_rawequal, lua_concatSupported
Callslua_callk / lua_call, lua_pcallk / lua_pcallSupported — the continuation runs when the call yields
Coroutineslua_newthread, lua_closethread, lua_xmove, lua_pushthread, lua_resume, lua_yieldk / lua_yield, lua_status, lua_isyieldableSupported — see Coroutines
Registry and referencesluaL_ref, luaL_unref, luaL_getsubtable, luaL_requirefSupported
Auxiliary libraryluaL_check*, luaL_opt*, luaL_argerror, luaL_typeerror, luaL_error, luaL_where, luaL_tolstring, luaL_newmetatable, luaL_testudata, luaL_checkudata, luaL_setfuncs, luaL_setmetatable, luaL_callmeta, luaL_getmetafield, luaL_fileresult, luaL_execresultSupported
luaL_tracebackHeader only — no frames
Strings and buffersluaL_buffinit, luaL_prepbuffsize, luaL_buffinitsize, luaL_addstring, luaL_addlstring, luaL_addvalue, luaL_addgsub, luaL_pushresult, luaL_pushresultsize, luaL_gsub, lua_numbertocstring, lua_stringtonumber, lua_pushvfstringSupported
GClua_gc with LUA_GCSTOP, LUA_GCRESTART, LUA_GCCOLLECT, LUA_GCCOUNT, LUA_GCCOUNTB, LUA_GCSTEP, LUA_GCISRUNNING, LUA_GCGEN, LUA_GCINC, LUA_GCPARAMSupported
Warningslua_setwarnf, lua_warningSupported — only reachable from C code
Standard library openersluaopen_base, luaopen_package, luaopen_coroutine, luaopen_debug, luaopen_io, luaopen_math, luaopen_os, luaopen_string, luaopen_table, luaopen_utf8Supported — they push the table clx already loaded
Upvalueslua_upvalueindex, lua_getupvalue, lua_setupvalue, lua_upvalueid, lua_upvaluejoinSupported

Not implemented

These functions exist but do nothing useful. A module that relies on any of them will misbehave silently rather than fail to link.

No state creation

FunctionBehaviour
lua_newstatereturns NULL
luaL_newstatereturns NULL
lua_closeno-op — the runtime stays alive
lua_setallocfno-op
lua_getallocfalways returns luaL_alloc, writes NULL to ud

Loading and dumping bytecode

clx compiles Lua to native code ahead of time, so there is no runtime compiler and no bytecode.

FunctionBehaviour
lua_loadpushes "clx: lua_load is not supported (chunk '...')" and returns LUA_ERRSYNTAX
luaL_loadbufferxpushes "clx: cannot load '<name>' at runtime" and returns LUA_ERRSYNTAX
luaL_loadstringsame as luaL_loadbufferx
luaL_loadfilexreturns LUA_ERRFILE if the file cannot be opened, otherwise LUA_ERRSYNTAX as above — the file is never compiled
lua_dumpalways returns 1 (failure); writer is never called

If you need to run Lua source at runtime, build with --dynamic and use load() from Lua — see Dynamic execution.

Debug and hooks

FunctionBehaviour
lua_getinfoalways returns 0
lua_getlocal / lua_setlocalalways returns NULL
lua_sethookno-op
lua_gethookreturns NULL
lua_gethookmask / lua_gethookcountreturns 0
lua_getstackreturns 1 only for level == 0 on a running thread, and zeroes lua_Debug; every other level returns 0. Since lua_getinfo then returns 0, there is no usable frame inspection
luaL_tracebackpushes only msg (if given), a newline and the literal stack traceback: — no frames

This matches the compiled-code side of clx: the debug library is not exposed as a compiled-code global either (see Lua 5.5 Compatibility).

To-be-closed variables

FunctionBehaviour
lua_tocloseno-op — the slot is never closed
lua_closeslotno-op

Other stubs

FunctionBehaviour
lua_checkstackalways returns 1; there is no fixed stack limit, so runaway recursion can exhaust memory instead of raising "stack overflow"
luaL_openselectedlibsno-op, which makes the luaL_openlibs(L) macro a no-op. Standard libraries are already open (unless you build with --minimal)
luaL_registernot present — removed from Lua in 5.2

Coroutines

The coroutine API works: lua_newthread, lua_resume, lua_yieldk, lua_status, lua_xmove and friends are covered by clx's test suite. A minimal round trip:

ccoro.c
static int l_work(lua_State *L) { lua_Integer n = luaL_checkinteger(L, 1); lua_pushinteger(L, n + 1); lua_yield(L, 1); /* hand 42 to whoever resumed us */ lua_pushinteger(L, 100); /* runs on the second resume */ return 1; } static int l_run(lua_State *L) { lua_State *co = lua_newthread(L); /* L: [co] */ lua_pushcfunction(co, l_work); /* co: [l_work] */ lua_pushinteger(co, 41); /* co: [l_work, 41] */ int nres = 0; int st = lua_resume(co, L, 1, &nres); /* LUA_YIELD, nres == 1 (42) */ if (st == LUA_YIELD) st = lua_resume(co, L, 0, &nres); /* LUA_OK, nres == 1 (100) */ lua_xmove(co, L, nres); /* L: [co, 100] */ lua_remove(L, -2); /* L: [100] — drop the thread */ lua_pushinteger(L, st); lua_pushinteger(L, nres); return 3; } int luaopen_coro(lua_State *L) { static const luaL_Reg funcs[] = { {"run", l_run}, {NULL, NULL} }; luaL_newlib(L, funcs); return 1; }
luamain.lua
local coro = require("coro") local v, st, nres = coro.run() assert(st == 0 and nres == 1 and v == 100)

As in stock Lua, lua_yield(L, n) hands its top n values to the resumer and the values passed to the next lua_resume become the values lua_yield "returns" (their count is what lua_yield evaluates to).

Notes on the contract:

  • lua_resume(L, from, narg, nres) expects the arguments to already be on L's stack. Stock usage moves them there first with lua_xmove(from, L, narg); from is only used to check that both states belong to the same runtime.
  • Results and error objects are pushed onto L's stack; *nres is set to their count. On LUA_ERRRUN exactly one value is pushed.
  • lua_status returns LUA_OK for the main thread and for a freshly created thread, LUA_YIELD after a yield, LUA_OK for a coroutine that finished normally, and LUA_ERRRUN for one that died with an error.
  • lua_isyieldable returns 0 on the main thread and inside lua_pcallk with k == NULL, matching stock's "cannot yield across a C-call boundary" rule.
  • lua_yieldk(L, n, ctx, k) yields like lua_yield, then runs k(L, LUA_YIELD, ctx) after the coroutine is next resumed and returns whatever k returns. If k is NULL, it returns the number of values the resumer passed.
  • lua_newthread shares the single runtime — same globals, same registry, same GC. lua_xmove between threads of different states is impossible because there is only one state; it raises "moving among independent states is not supported".
  • lua_closethread(L, from) on a still-suspended coroutine runs clx's close_thread, which releases the fiber rather than silently dropping it; the thread ends up DEAD.
  • Threads created by clx's own coroutine.create are resumable from C too: lua_tothread on a Lua thread value gives you a lua_State* whose function is already set.

Known deviations

BehaviourStock Luaclx
lua_yieldk with a continuationnever returns to the caller of lua_yieldkreturns k(L, LUA_YIELD, ctx)'s value; with k == NULL it returns the number of resumed values, so it "returns" like lua_yield does in Lua code
Resuming the main threadruns the main function if not yet started"cannot resume non-suspended coroutine"
lua_resume on a never-started thread with neither a stack function nor t->functionerror"cannot resume dead coroutine"

Errors

Errors are C++ exceptions (clx::LRuntimeException) unwinding through your C frames, not longjmp:

  • lua_error(L) and luaL_error(L, fmt, ...) never return — they throw. Both return luaL_error(...) and bare luaL_error(...) work.
  • lua_pcallk / lua_pcall catch the exception, run your error handler if you passed one, and return LUA_ERRRUN (or LUA_ERRERR if the handler itself fails). Use them exactly as you would in stock Lua.
  • An error escaping an unprotected lua_call propagates out to the nearest pcall in your Lua code, or to the program's top level if there is none.
  • lua_atpanic is stored but never called. clx has no panic path: an unrecoverable error prints the message and exits with status 1. Do not write a panic function that is expected to longjmp or exit for you.
  • luaL_where(L, 1) prefixes the message with file:line: taken from the most recent Lua call site, when clx knows it; otherwise it pushes "". Inside a luaopen_* at startup there is usually no position yet, so luaL_error messages come out unprefixed.
  • Error objects behave as in stock: any Lua value can be raised, and lua_tolstring on a raised value gives you the message. lua_pushvfstring supports the same conversions as stock — %s %c %d %I %f %p %U %% — and copies unknown specifiers through unchanged.

What differs from stock Lua

You must rebuild against clx's headers

lua.h renames every lua_* and luaL_* symbol to clx_lua_* through #defines placed above the declarations. That keeps the bridge from colliding with a real Lua library that some other dependency might link.

The consequence: an object file compiled against stock Lua's headers cannot be linked into a clx program. Rebuild every C module from source with -I/path/to/clx/include. Your own luaopen_<name> symbols are not renamed, which is how --modules finds them.

luaopen_base, luaopen_math and friends are renamed (clx_luaopen_math) because clx defines them itself; include lualib.h and call them normally.

The stack is per-bundle and per-frame

Each lua_State* owns its own value stack. Inside a lua_CFunction you see only your own arguments, as in stock Lua. Strings returned by lua_pushlstring/lua_pushstring/lua_tolstring point into clx's interned string arena and stay valid as long as the value is reachable — and lua_tolstring converts numbers in place, exactly like stock.

Short strings (six bytes or fewer) are stored inline in the value. lua_tolstring re-interns them before returning a pointer, so the usual "the pointer is valid while the value stays on the stack" guarantee holds.

Notes and differences

AreaNote
luaL_checkoptionreturns the int index of the chosen option, not a pointer — this is a Lua 5.5 change, not a clx quirk
lua_callk / lua_pcallkthe lua_KFunction continuation runs exactly when the call yields: after the call completes, clx runs k(L, LUA_YIELD, ctx) (or k(L, status, ctx) if it failed after the yield) and abandons the C function, using k's return value as its result count — as stock does. If the call never yields, k is not called and lua_pcallk returns normally
yield through lua_callkstock allows it only when k != NULL; clx always allows it
yield through lua_pcallklike stock: allowed when k != NULL (the continuation runs on resume), refused with k == NULL — you get "attempt to yield across a C-call boundary"
luaL_pushfaila header macro, so identical to stock: pushes nil, or false if you define LUA_FAILISFALSE
lua_newuserdatauv(L, sz, nuvalue)nuvalue is ignored — you can store any number of user values, and out-of-range lua_getiuservalue/lua_setiuservalue indices return LUA_TNONE / 0 instead of failing
lua_pushexternalstring(L, s, len, falloc, ud)clx copies the bytes into its own arena and calls falloc(ud, s, len+1, 0) immediately, so your buffer must be freeable right away and must not be reused
luaL_testudata(L, idx, "FILE*")returns a pointer to a single shared scratch slot in the state, overwritten by the next such call. Copy the FILE* out before calling it again
luaL_openlibsno-op; libraries are opened by the runtime. With --minimal they are absent and the C API cannot bring them back
Warningslua_setwarnf/lua_warning work for C-to-C calls, but clx's own runtime warnings do not route through them
Module initialisationopeners run at program startup, before the entry chunk, and all of them run regardless of whether anything calls require
lua_getextraspacebacked by real reserved bytes in front of every lua_State, so the macro behaves as in stock

Checking your module

A quick smoke test is enough to catch most wiring problems:

shSmoke test
# 1. build the module archive $ cc -c -O2 -I/path/to/clx/include lfs.c -o lfs.o && ar rcs lfs.a lfs.o # 2. link and run $ clx check.lua --modules lfs -o check && ./check
luacheck.lua
local m = require("lfs") assert(type(m) == "table", "opener did not return a module table") print("ok")

If clx reports input module "lfs" collides with the precompiled C module, your .lua file shares its stem with the archive — rename one of them. If the archive is treated as a C++ module, check that luaopen_lfs was not mangled (nm -g lfs.a | grep luaopen).

See also

  • Modules — building, finding and linking module archives, and the libclx_capi wrapper archive
  • C++ API — the native, stack-free C++ API for new code
  • doc/migration-guide.md — porting a C module to the C++ API
  • Lua 5.5 Compatibility — language and library support
  • Dynamic execution — running Lua source at runtime

Lua 5.5 Compatibility

clx targets Lua 5.5 compatibility. The entire core language, control flow, tables, metatables, coroutines, and standard libraries are supported out of the box — the items below are grouped by area.

Core Language
Variables Arithmetic operators Logical operators Comparisons Functions Closures _ENV Varargs Multiple returns Local & global variables
Control Flow
if / elseif / else while repeat / until numeric for generic for break goto & labels
Tables
Table constructors Array part Hash part Mixed tables Table iteration
Metatables
__index / __newindex Arithmetic metamethods __len / __concat / __eq / __lt / __le __call / __tostring __ipairs / __pairs
Coroutines
create / resume / yield status / wrap
Standard Libraries
base math string table coroutine io os utf8 package debug

Every library above ships in the AOT runtime except debug, which is only available in the embedded VM via --dynamic.

Conditional & Unsupported in AOT

These run in the embedded Lua VM rather than the AOT-compiled binary, so they require --dynamic:

load()
Requires --dynamic — the current bridge accepts source strings, not reader functions.
loadfile()
Requires --dynamic — compiles a file in the embedded Lua VM.
dofile()
Requires --dynamic — loads and executes a file through the bridge.
require() of file modules
Requires --dynamic — loads package.path matches and executes them on the embedded VM; bundled and --modules modules always work.
string.dump()
Not provided by the clx AOT runtime; available in the embedded VM path.
debug library
Not provided as a clx AOT global; embedded VM behavior applies to dynamic code.