Lua 5.3.4 in pure Java. The JVM handles the garbage collection.
javalua is a module-by-module port of the Lua 5.3.4 reference implementation to Java. Existing Lua 5.3 programs run unchanged: the full language, the complete standard library, binary chunks, debug hooks, and native C modules.
- Full Lua 5.3.4 semantics — hand-written lexer, parser and code generator,
plus a register VM ported from
lvm.c.goto, varargs, multiple returns, integer/float subtypes, and every metamethod including__gcand__mode. - Complete standard library —
base,coroutine,package,string(includingpack/unpack),table,math,io,os,utf8,bit32(LUA_COMPAT_5_2), anddebugwith hooks. - Memory by the JVM —
lgc.cis not ported. Weak tables useWeakReference/ReferenceQueue,__gcuses a resurrection sentinel, and string interning uses a weak pool.collectgarbagekeeps Lua's observable bookkeeping but delegates collection to the JVM. - No threads for coroutines — a coroutine is a separate Lua stack, and
coroutine.yieldunwinds through aLuaYieldexception, the analogue of C'slongjmp. Yielding throughpcall, metamethods and iterators works; yielding across a C-call boundary raises the same error as the reference. - Binary chunks —
load,loadfile,dofile,requireandstring.dumpread and write Lua 5.3.4 bytecode. - C API —
luashim, a small JNI library, exports thelua_*/luaL_*surface, so real C modules built for Lua 5.3 (lpeg, cjson, lfs, luabitop, mime, luaposix, …) load throughpackage.loadlibandrequire. It also gives the runtime the few libc calls Java has no equivalent for:setlocale(and with it the locale radix character thattonumber,tostring,string.formatandio.read("n")all go through),waitpid,FILE*. - Java 21, a single jar, no runtime dependencies.
<dependency>
<groupId>com.github.esrrhs</groupId>
<artifactId>javalua</artifactId>
<version>1.0.0</version>
</dependency>import com.github.esrrhs.javalua.LuaLibs;
import com.github.esrrhs.javalua.LuaState;
import com.github.esrrhs.javalua.LuaValue;
import java.nio.file.Files;
import java.nio.file.Path;
LuaState L = new LuaState();
LuaLibs.openAll(L);
L.run("print('hello from Lua')");
LuaValue err = L.runProtected(
Files.readAllBytes(Path.of("script.lua")), "@script.lua");
if (err != null) {
System.err.println(err);
}
L.close();run throws LuaError on failure; runProtected returns the error value
instead. Each LuaState owns its globals, registry and coroutines, so
independent states can be used side by side.
native/build.sh compiles native/luashim.c into
src/main/resources/native/<os>-<arch>/, where NativeBridge finds and loads
it automatically. Building needs a C compiler and the JDK headers:
sh native/build.shA custom build can be selected at startup with
-Djavalua.luashim=/path/to/libluashim.so. With the shim loaded,
require("lpeg") and package.loadlib(path, "luaopen_x") behave as in C Lua.
Without it, package.loadlib reports
dynamic libraries not enabled; check your Lua installation.
The published jar does not bundle a shim (src/main/resources/native/ is
git-ignored and built per machine), so running it out of the box gives you the
pure-Java paths: io reads and writes through RandomAccessFile, and only C
module loading is missing. Force either path for testing with
-Djavalua.nonative=true, which pretends the shim failed to load.
Correctness means agreeing with a real Lua 5.3.4 binary, not looking similar. Four gates run against a reference build:
- Upstream suite — Lua's own
lua-5.3.4-testsrun unmodified. - Differential tests — the same script runs under both implementations and stdout must match byte for byte. This covers language semantics, the standard library, error messages, tracebacks and bytecode disassembly.
- Differential fuzzing — deterministic generated programs (including
coroutine and precompiled-chunk modes) are compared against the reference.
Seed counts are configurable with
-Dfuzz.seedsand-Dfuzz.coSeeds. - Coverage — JaCoCo line coverage, measured over both
iopaths together, must stay at or above 90%.
CI has three jobs: Build (JDK 21), Conformance (against lua5.3), and
Static analysis. The conformance job builds Lua 5.3.4, the fkdump bytecode
dumper and luashim from source, generates a comma-decimal locale (pt_BR,
otherwise the locale tests would pass while proving nothing), then runs the
full test suite twice — once with the shim, once with -Djavalua.nonative=true
so the pure-Java io path is diffed against the reference too. Each run is
followed by tools/check_ci_gates.py, which fails when a gate class executed
zero tests; the two runs append into one jacoco.exec, and the 90% line is
checked against that merged file.
Intentional / unfixable platform notes (everything else is expected to match Lua 5.3.4):
-
math.random/randomseed(accepted platform note): API matches 5.3.4 (one seed argument, no dual-seed return,random(0)is an empty interval). Sequences usejava.util.Random, not libcrand/srand, so numeric streams differ from the reference binary by design and are not treated as alignment gaps. -
Table hash visit order (non-integer keys): Open-addressed hash placement for pointer/object keys can still differ from PUC-Rio when insertion histories diverge;
#t/sizearray/hashintfollow Lua 5.3.4 (unbound_search,hashpow2for integers). Visit-order-only diffs for non-integer keys are accepted when contents match. -
string.dump/ undump: Boolean constants use the 5.3.4LUA_TBOOLEAN+value layout. Selfdump→loadworks for common shapes (seeBinaryChunkTest/DumpTest). Strippedstring.dumpmatches stocklua5.3.4 for the codegen shapes exercised in Diff; unstripped dumps still differ when chunk source names differ (@pathvs bare path). -
GC: memory is owned by the JVM (
lgc.cis not ported). Weak tables,__gc, andcollectgarbagekeep Lua-observable shapes where possible, butcollectgarbage("count")reports JVM heap usage (absolute values differ; relative changes are still meaningful), andcollectgarbage("step")may report cycle completion differently because there is no incremental Lua collector to step. Upstreamgc.lua/ collector-param tests are not differential gates. -
Transcendental
mathfunctions can differ from the platform libc by a few ulp (JavaMath/ fdlibm vs the reference's libc). -
%pand the defaulttostringof userdata/threads print Java identities, not C addresses. -
NaN sign in text: C99 leaves the sign of a NaN unspecified. This port follows the IEEE sign bit like glibc (
-nanwhen the sign bit is set;+nan/nanunder%+/%for a positive NaN). Apple libc may still print unsignednanfor a negative NaN; differential tests normalize both sides so macOS hosts stay comparable. Math-library NaN payloads can still differ from libm for some domain errors;asin/acosare canonicalized to a positive quiet NaN to match glibc. -
With the shim,
os.setlocalereally switches libc's locale, which is per-process: everyLuaStatein the same JVM shares it, while C Lua has one state per process. The radix character thattonumber,tostring,string.format,io.writeandio.read("n")use follows it (lua_getlocaledecpoint()), andos.datelocale names (%A,%B,%c, …) follow it too via libcstrftime. -
Without the shim,
os.execute/io.popen:closecan only report("exit", 128+n)for a signal-killed process (e.g. undrained close →("exit", 141)instead of("signal", 13)),os.setlocaleonly acceptsnil/"C"/"POSIX", the radix character is always., andos.datename conversions stay on the English tables. -
With the shim,
io.popenundrained-close while the child is still writing (sleep; echo, largeprintf) agrees with C onsignal 13. A tinyechothat already finished can still yieldexit 0on both sides — a shared timing window. Absoluteos.clockvalues still differ from a freshluaprocess because of JVM startup CPU; the function measures process CPU time like C'sclock().
./mvnw test # differential tests skip when no Lua 5.3.4 binary is found
./mvnw packagePoint the tests at a reference binary to enable all gates:
LUA53_BIN=/path/to/lua5.3 ./mvnw testDiscovery order is LUA53_BIN, then target/lua53/src/lua, then lua5.3 or
lua on PATH (the version is checked to be 5.3.4). The differential harness
writes its scratch files under target/rt/<pid>/ — one directory per JVM — so
two ./mvnw test runs at the same time cannot read each other's case file.
Optional variables:
| Variable | Purpose |
|---|---|
LUA53_BIN |
reference interpreter |
LUAC53_BIN |
reference luac |
FKDUMP_BIN |
bytecode dumper built against the reference |
LUA53_TESTS |
unpacked upstream test suite |
LUA53_LIB |
reference liblua.a for the C API symbol sweep; otherwise tries target/lua53/src/liblua.a, then /tmp/lua-5.3.4/src/liblua.a |
Building a reference locally:
curl -LO https://www.lua.org/ftp/lua-5.3.4.tar.gz
tar xzf lua-5.3.4.tar.gz
make -C lua-5.3.4 posix MYCFLAGS=-DLUA_USE_DLOPEN MYLIBS="-Wl,-E -ldl"
LUA53_BIN=$PWD/lua-5.3.4/src/lua ./mvnw test| Path | Contents |
|---|---|
src/main/java/com/github/esrrhs/javalua/ |
lexer, parser, code generator, VM, state, metamethods, standard library |
native/ |
luashim.c, generated C API stubs, build script |
src/test/java/... |
unit, differential, fuzz, and native-extension tests |
benchmark/ |
local ballpark timing; not a CI gate (see its README) |
tools/ |
check_ci_gates.py — fails when a gate class silently ran zero tests; local_conformance.sh — mirrors the CI conformance job locally (both io paths, bounded runs, the same gate counts and coverage thresholds for the single pass and the merged data) |
The Java classes mirror the C sources: Lexer ↔ llex.c, Parser ↔
lparser.c, Codegen ↔ lcode.c, Vm ↔ lvm.c, LuaState ↔
lstate.c/ldo.c, Api ↔ lapi.c, LuaAux ↔ lauxlib.c, and one class per
library file.
MIT