Skip to content

Repository files navigation

Mawu

Lua as a first-class source language on the JVM, compiled at build time.

Version Kotlin Build JVM Lua License

Overview

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.

How It Works

  1. Your project applies Mawu and gains a Lua source set (src/main/lua).
  2. You write .lua files there instead of .kt or .java.
  3. During the build, Mawu compiles every script into a Luak Prototype: Lua bytecode, not JVM .class files.
  4. Those precompiled prototypes are packaged as resources inside the final jar.
  5. At runtime your application loads a prototype, binds it to a Luak Globals and 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.

Design

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.

Requirements

Component Requirement
JDK 17 or later
Build Gradle, through the project's own wrapper
Runtime net.blueva:luak-jvm, resolved transitively

Installation

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

What the Build Checks

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' and java.util.logging.Logger are 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.

Configuration

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

What Ends Up in the Jar

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.

Running Compiled Scripts

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.

Calling Between Java and Lua

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

Java calling Lua

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

Lua calling Java

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

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

What crosses

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

What a script can reach

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.

Authors

  • Blueva
  • Whiron

Website: blueva.net

License

Mawu is distributed under the GNU General Public License v3.0.

About

Lua as a first-class source language on the JVM, compiled to Luak bytecode at build time

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages