Lua as a first-class source language on the JVM, compiled at build time.
Mawu is a build plugin that lets you write part of a JVM project in Lua, the same way the Kotlin plugin lets you write it in Kotlin. Lua sources live in their own source set, the build compiles them, and the result travels inside your project's jar alongside everything else.
Execution is delegated to Luak, a Kotlin Multiplatform implementation of an embeddable Lua 5.5.1 runtime. Mawu owns the build pipeline and the runtime loading around it; interpretation, bytecode compilation and JIT stay in Luak, where they are already solved.
- Your project applies Mawu and gains a Lua source set (
src/main/lua). - You write
.luafiles there instead of.ktor.java. - During the build, Mawu compiles every script into a Luak
Prototype: Lua bytecode, not JVM.classfiles. - Those precompiled prototypes are packaged as resources inside the final jar.
- At runtime your application loads a prototype, binds it to a Luak
Globalsand runs it.
The point of steps 3 and 4 is that compilation happens once, at build time. Starting a lane at runtime becomes loading finished code, rather than parsing and compiling a script on every boot.
Mawu does not translate Lua into JVM bytecode. Lua's semantics are dynamic (metatables, varargs, coroutines, _ENV, runtime load), and lowering them onto static JVM bytecode means reimplementing a Lua VM inside a compiler. Luak already runs Lua properly, so Mawu delegates every semantic question to it and keeps a single responsibility: getting Lua into the build and out of the jar.
A compiled Prototype is finished code and constants, never written to again, so it is safe to share across as many independent Lua environments as you need. Environments themselves are never shared: math.random carries a generator, io carries open files, and package carries what has been required. Compile once, bind per lane.
| Component | Requirement |
|---|---|
| JDK | 17 or later |
| Build | Gradle, through the project's own wrapper |
| Runtime | net.blueva:luak-jvm, resolved transitively |
Artifacts are published to repo.blueva.net, a public Maven repository, so no authentication is needed.
Gradle (Kotlin DSL)
plugins {
java
id("net.blueva.mawu") version "26.3"
}
repositories {
maven("https://repo.blueva.net/releases")
}Every source set gains a Lua directory, so sources are picked up from:
src/
main/
kotlin/
lua/ <- your .lua files
resources/
test/
lua/ <- compiled by compileTestLua
The plugin puts net.blueva:mawu-runtime on the project's implementation
classpath, which brings net.blueva:luak-jvm with it. Building runs
compileLua, which writes one prototype per script into the jar, and
compileTestLua for the test source set. Only the scripts that changed are
compiled again.
To read Lua from somewhere else as well:
import net.blueva.mawu.gradle.lua
sourceSets {
main {
lua {
srcDir("scripts")
}
}
}compileLua parses every script, so the build fails on anything Lua itself
refuses to compile, at the file and line that caused it:
> Lua compilation failed in 2 sources:
/project/src/main/lua/first.lua:1: syntax error near 'is'
/project/src/main/lua/second.lua:4: 'end' expected (to close 'function' at line 1) near <eof>
Every source is checked before the build gives up, so one broken file does not
hide the next. What is caught is what a Lua compiler catches: syntax, unclosed
blocks and strings, goto and break with no label to reach, assignment to a
<const>, and the compiler's own limits.
Two more checks read the compiled form rather than the source, so they cost a walk over instructions and no second parser. Both fail the build, because a name that is wrong is wrong whether Lua notices it now or three frames into a run:
- Undefined globals. A global a script reads that no script writes, that the standard library does not have, and that the build was not told about.
- Names off the classpath.
import 'a.b.C'andjava.util.logging.Loggerare constants in the bytecode, so the class or package they name is looked up while it is still just a string. The same walk follows what a call returned, so a method missing from the object in hand is caught too.
> Undefined globals:
/project/src/main/lua/main.lua:12: undefined global 'confguration'
> Unknown classes:
/project/src/main/lua/main.lua:2: no class or package named 'java.util.logging.Loggerr'
/project/src/main/lua/main.lua:7: 'java.util.logging.Logger' has no member 'infoo'
Both take MawuSeverity.WARN to report without failing, or IGNORE to skip:
mawu {
// Globals your host installs before running a script.
knownGlobals.addAll("housing", "player")
undefinedGlobals.set(MawuSeverity.WARN)
unknownClasses.set(MawuSeverity.WARN)
}A value that crosses into Lua as a string or a number stops being a Java object
there, so logger:getName():upper() is Lua's string library and is left alone.
A name built at runtime is not a constant and cannot be checked at all, which
is the case the severities exist for.
What stays unchecked is what only running settles: a function called with the
wrong number of arguments, a method a Lua table does not have, a field of a
value that turns out to be nil. Those surface when the line runs, the same
way they do in Lua itself.
mawu {
// Drop line numbers and local names from compiled scripts. Off by default:
// a stack trace from a running script is worth more than the bytes.
stripDebugInfo.set(false)
// Add mawu-runtime to the implementation classpath. On by default.
addRuntimeDependency.set(true)
// Version of mawu-runtime to depend on. Defaults to the plugin's own.
runtimeVersion.set("26.1")
}Compiled scripts travel as resources, addressed by the path they had in the source set, without the extension:
META-INF/mawu/scripts/hello.luac
META-INF/mawu/scripts/lanes/worker.luac
META-INF/mawu/scripts.index
src/main/lua/lanes/worker.lua is therefore the script id lanes/worker. The
index lists every id a jar carries, so an application can enumerate what it was
built with. Nothing here is a .class file, and nothing is compiled again at
startup.
MawuScripts reads the prototypes out of the classpath:
import net.blueva.luak.lib.jvm.JvmPlatform
import net.blueva.mawu.runtime.MawuScripts
val globals = JvmPlatform.standardGlobals()
MawuScripts.run(globals, "hello")load(id) returns the prototype, bind(globals, id) returns it as a callable
function, and ids() lists everything on the classpath. Bind one prototype into
as many environments as you need: each is independent, so one script cannot
reach into another's state.
Prototypes are undumped directly rather than passed through Globals.load, so
they still load in an environment configured to refuse binary chunks.
A MawuLane is one Lua environment plus the calls that cross it in both
directions. Lanes are independent: what one exposes, another never sees.
import net.blueva.mawu.bridge.MawuLane
val lane = MawuLane()// A module script, one that ends in: return { greet = function(name) ... end }
val api = lane.run("api")
lane.callFunction(api.get("greet"), "world") // "hello world"
lane.call("greet", "world") // a global function
lane.call("net.request", 21) // one nested in a table
lane.callMethod(api, "add", 2) // colon style: function M:add(value)Or with types, by handing the table to a Java interface:
public interface Greeter {
String greet(String name);
default String shout(String name) { return greet(name).toUpperCase(); }
}
Greeter greeter = lane.module("api", Greeter.class);
greeter.greet("world");Calls go to the table by name, so the script writes function M.greet(name)
rather than function M:greet(name). A method the table has no function for
falls back to the interface's own default, and fails by name if there is none.
A script names the classes it wants. Nothing has to be registered from the host first:
local Logger = java.util.logging.Logger
local log = Logger:getLogger('my.plugin')
log:info('hello')
local Thing = import 'net.blueva.example.Thing'
local text = java.lang.StringBuilder.new('a'):append('b'):toString()
local level = java.util.logging.Level.WARNINGA dependency of the build is on the classpath like anything else, so a script reaches a third-party library by name too:
dependencies {
implementation("org.apache.commons:commons-lang3:3.19.0")
}local StringUtils = import 'org.apache.commons.lang3.StringUtils'
result = StringUtils:reverse('mawu')Statics and fields come off the class (Class:method(...), Class.FIELD),
constructors are Class.new(...), instance methods are object:method(...),
and a nested class is written with a dot, as java.util.Map.Entry. Overloaded
methods pick the overload that fits the arguments.
What the host hands over is live values, not permission:
lane.expose("calculator", Calculator()) // an object that already exists
lane.exposeClass("Logger", Logger::class.java) // a short name for a long one
lane.exposeFunction("log") { args -> println(args.first()); null }
lane.exposeFunctions("host", mapOf("sum" to MawuFunction { args -> args.sumOf { it as Int } }))local total = calculator:add(40, 2)
log('total is ' .. total)
local sum = host.sum(1, 2, 3)| Lua | Java |
|---|---|
nil |
null |
| boolean | Boolean |
| integer | Integer |
| float | Double |
| string | String |
| a Java object the host exposed | itself, methods and all |
| table, function | LuaValue, or a Java interface through module/proxy |
A lane's environment is JvmPlatform.standardGlobals() by default, plus java
and import. The scripts are your project's own source, as trusted as the
Kotlin next to them, so they reach the classpath the same way it does.
Two ways out of that, for a host that runs Lua it did not write:
MawuLane(exposeClasspath = false) drops java and import, and passing a
Globals of your own decides the rest. Luak has the bounds for that case (an
instruction budget, a memory ceiling, no binary chunks), and they work on
Mawu-compiled prototypes like on any other.
- Blueva
- Whiron
Website: blueva.net
Mawu is distributed under the GNU General Public License v3.0.