Documentation
Everything you need to embed luna in a Rust host — from a three-line hello to the stable API contract. Snapshotted against v2.16.0. The rustdoc API reference is on docs.rs.
Install & hello
luna is a Cargo workspace with five publishable crates. Pick the dependency footprint that fits: luna-jit for the full interpreter plus Cranelift JIT and the C ABI, or luna-core for a zero-dependency, wasm-friendly interpreter.
# full interpreter + Cranelift JIT + C ABI
luna-jit = "2"
# or the minimum surface — pure interpreter, zero third-party deps
luna-core = "2"Construct a Vm for a chosen dialect and evaluate. Vm::new opens every safe-by-default stdlib library and installs the JIT (a no-op backend under luna-core).
use luna_jit::vm::Vm;
use luna_jit::version::LuaVersion;
fn main() {
let mut vm = Vm::new(LuaVersion::Lua55); // 5.5 + full stdlib + JIT on
let result = vm.eval("return 'hello, ' .. 'world'").unwrap();
let s: String = result[0].try_as_str().unwrap().to_string();
println!("{s}");
}Command-line interface
Installing luna-jit puts a luna binary on your path — a REPL and script runner.
luna script.lua # run a file
luna -e "print(1 + 2)" # run inline code
luna --lua=5.4 script.lua # pick a dialect
luna # interactive REPL (Ctrl-D exits)| Flag | Behavior |
|---|---|
| --lua=5.X | Select dialect (5.1 / 5.2 / 5.3 / 5.4 / 5.5; default 5.5) |
| --sandbox | Open base/math/string/table/coroutine only; reject bytecode loading |
| --budget=N | Set an instruction budget before running |
| --no-jit | Interpreter-only run (installs the null JIT backend) |
| --profile | Print trace-JIT counters after the script finishes |
| -e "<code>" | Run inline code instead of a file |
| - | Read source from stdin |
The REPL evaluates each line first as an expression (prefixed with return), then retries as a statement on syntax error — so both expressions and assignments work.
Embedding
The embedding API is the primary surface — richer than the C ABI, with fully safe ownership. Below is the shape of each capability; the full cookbook covers 14 sections including proc-macro userdata and the async surface.
Sandbox for untrusted scripts
The sandbox builder whitelists libraries and arms the budgets. A sandboxed script cannot require, touch the filesystem, or compile bytecode chunks.
use luna_jit::Lua;
use luna_jit::version::LuaVersion;
let mut lua = Lua::sandbox(LuaVersion::Lua54)
.open_base()
.open_math()
.open_string()
.open_table()
.with_instr_budget(1_000_000) // ~10 ms wall-clock budget
.with_memory_cap(8 * 1024 * 1024) // 8 MiB cap
.build();
let r: i64 = lua.eval("return 1 + 2").unwrap();Globals & tables
set_global accepts any IntoValue type (with Option<T> mapping to nil). Tables come in a one-shot and a chained builder form.
vm.set_global("answer", 42_i64)?;
vm.set_global("name", "luna")?;
vm.set_global("missing", Option::<i64>::None)?; // sets to nil
// one-shot, fixed shape:
let t = vm.table_of([("answer", 42_i64), ("year", 2026_i64)]);
// or a chained builder for variable shapes:
let t = vm.new_table()
.with("name", "luna")
.with(1_i64, "first array entry")
.build();Typed native functions
native_typed bridges a Rust closure or fn-pointer to Lua with automatic argument decode and return encode — pure, multi-return, or fallible (Result<T, LuaError>), for arities 0 through 6.
let add = vm.native_typed(|a: i64, b: i64| -> i64 { a + b });
vm.set_global("add", add)?;
let split = vm.native_typed(|x: i64| -> (i64, i64) { (x / 10, x % 10) });
vm.set_global("split", split)?;
let safe_div = vm.native_typed(|a: i64, b: i64| -> Result<i64, LuaError> {
if b == 0 { Err(LuaError::new(Value::Nil)) } else { Ok(a / b) }
});
vm.set_global("safe_div", safe_div)?;Userdata — exposing host types
Stash any T: 'static Rust value behind Lua userdata. An empty impl LuaUserdata for T {} is enough to bridge; add methods and metamethods to make it feel native. A #[derive(LuaUserdata)] proc-macro generates the boilerplate.
use luna_core::vm::{LuaUserdata, MetaMethod, UserdataMethods};
struct Counter { value: i64 }
impl LuaUserdata for Counter {
fn type_name() -> &'static str { "Counter" }
fn add_methods<M: UserdataMethods<Self>>(m: &mut M) {
m.add_method("get", |_vm, this, ()| Ok::<_, _>(this.value));
m.add_method_mut("incr", |_vm, this, (by,): (i64,)| {
this.value += by; Ok::<_, _>(())
});
m.add_meta_method(MetaMethod::ToString, |_vm, this, ()| {
Ok::<_, _>(format!("Counter({})", this.value))
});
}
}
vm.set_userdata("c", Counter { value: 100 })?;
vm.eval("c:incr(50); print(tostring(c))")?; // → Counter(150)Coroutines & debug hooks from Rust
Drive Lua coroutines from Rust without Lua-side coroutine.create via create_coroutine / resume_coroutine. Install a Rust callback on Call / Return / Line / Count / TailCall events with set_rust_debug_hook.
let body = vm.eval(r#"
return function()
coroutine.yield(1)
coroutine.yield(2)
return 3
end
"#)?[0];
let co = vm.create_coroutine(body);
let r1 = vm.resume_coroutine(co, vec![])?; // r1[0] == Int(1)
let r2 = vm.resume_coroutine(co, vec![])?; // r2[0] == Int(2)
let r3 = vm.resume_coroutine(co, vec![])?; // r3[0] == Int(3), terminalErrors
Lua errors surface as Result<T, LuaError>. LuaError implements Display / Error; richer context is available from the Vm.
match vm.eval("error('something failed')") {
Ok(v) => println!("ok: {:?}", v),
Err(e) => {
println!("error: {}", e); // Display
let kind = vm.error_kind(); // LuaErrorKind
let source = vm.error_source(); // Option<(&str, u32)>
let tb = vm.take_error_traceback(); // Option<String>
}
}The Lua facade
If you prefer an mlua-shaped front door, Lua wraps the same machinery with Copy + Clone handles backed by host-root tickets.
use luna_jit::Lua;
let mut lua = Lua::new(); // JIT on, Lua 5.5
lua.open_base(); lua.open_math();
let add = lua.create_function(|a: i64, b: i64| -> i64 { a + b });
lua.set_global("add", add)?;
let r: i64 = lua.eval("return add(40, 2)")?; // 42
let t = lua.create_table();
t.set(&mut lua, "name", "luna")?;
let name: String = t.get(&mut lua, "name")?;Threading
The default Vm (and the Lua facade) is !Send + !Sync — a Vm and every handle into its GC heap lives on the thread that created it. The constraint is enforced at compile time by a compile_fail doctest.
This is a deliberate performance choice: the GC uses Gc<T> = NonNull<T> over an intrusive mark-sweep heap, with no stop-the-world protocol and no locked trace-cache reads. Making it Send would cost 5–15% on real workloads. Lua's own data model is single-threaded to begin with.
Three canonical patterns
- Single-thread Tokio — run the executor on the thread the Vm lives on with flavor = "current_thread", and use vm.eval_async(...).
- LocalSet on multi-thread Tokio — LocalSet::run_until pins the Vm's futures to the calling thread.
- Vm per OS thread + channels — for real parallelism, one Vm per thread; only Send data (source strings, result strings) crosses between them.
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut vm = Vm::new_minimal_with_jit(LuaVersion::Lua55);
vm.open_base();
let result = vm.eval_async("return 1 + 2").await?;
Ok(())
}Security & sandbox
luna is an embedded VM: the host, not the script, owns the security boundary. Every capability a script sees was opted in by Rust code.
In scope
- Script-driven OS-access exfiltration (filesystem, environment, network, child processes) — blocked beyond what the host exposed.
- Resource exhaustion — a per-call instruction budget and an approximate memory cap force a catchable Lua error.
- Bytecode-loader escape — luna's own dump format and PUC .luac loading are off by default in a sandbox.
- Zero unsafe at the embedder surface — every cargo doc-visible API is safe Rust.
Out of scope
- VM correctness bugs (report as a normal bug, not a sandbox escape), side-channel resistance, and supply-chain integrity.
- Denial of service from legal-but-pathological scripts — the budgets bound CPU and memory per call, not output volume to a host sink.
- debug.*, and os.execute / io.popen once opened — these are equivalent to handing over the Vm or a shell.
Deployment
Four packaging shapes cover most hosts. luna does no logging of its own — observability is hooked through the debug-hook mechanism and host counters around Vm::eval.
| Shape | Crate | Notes |
|---|---|---|
| Standalone binary, frozen source | luna-aot | Build-time; no runtime crate on host |
| Rust service, JIT enabled | luna-jit | Pulls Cranelift; cross-thread via feature = "send" |
| Rust service, interpreter only | luna-core | Zero third-party deps |
| WASM (browser / wasmtime) | luna-core | wasm32-wasip1; JIT off, io/os stubbed |
For the smallest container, static-link against musl and build FROM scratch; strip before copying to drop most of __LINKEDIT.
FROM scratch
COPY ./target/x86_64-unknown-linux-musl/release/service /service
ENTRYPOINT ["/service"]JIT & AOT
The trace JIT is on by default under luna-jit. A handful of sticky knobs let you tune or disable it for predictable latency and A/B testing.
| Knob | Default | Effect |
|---|---|---|
| set_jit_enabled(false) | true | Disable for predictable latency / debug repro |
| set_trace_jit_enabled(false) | true | A/B the trace JIT against the interpreter |
| set_hot_threshold(n) | constant | Lower for hot-immediately workloads |
| set_max_trace_len(n) | constant | Raise for long unrolled loops |
Hot-path counters (trace_compiled_count, trace_dispatched_count, trace_aborted_count, trace_deopt_count) help diagnose whether a workload is actually getting JIT speedup.
Ahead-of-time compilation
luna-aot compiles a Lua source file into a self-contained native binary — parse, emit bytecode into a data section, warm hot traces under the recorder, link the static runtime, and produce the executable.
cargo install luna-aot
luna-aot compile hello.lua --out hello
./hello # standalone native binary
# cross-compile from a macOS host to Linux
luna-aot compile foo.lua --target x86_64-unknown-linux-gnu --out foo.linuxCranelift's all-arch backend cross-compiles to every target without a rebuild. A stripped release binary lands around 4.5 MiB; the runtime floor dominates, so a 1-line script and a 1.5k-LOC script differ by only ~82 KiB.
Compatibility
luna implements Lua 5.1 – 5.5 and MacroLua in one binary. The dialect is chosen per-Vm; a single process can host several at once. See the dialect datasheet on the home page for the per-feature matrix.
Standard library
The whitelisted subset suitable for sandboxed embedding is exposed via Vm::open_*() methods:
| Library | Method | Coverage |
|---|---|---|
| base | open_base | full |
| math | open_math | full |
| string | open_string | full (incl. pattern matching) |
| table | open_table | full |
| coroutine | open_coroutine | full |
| io / os | open_io / open_os | full (host-controlled) |
| utf8 | open_utf8 | full (5.3+) |
| debug | — | partial, not exposed by default |
C API
luna ships a cdylib / staticlib exposing a lua.h-compatible C ABI subset. Existing PUC consumers linking against liblua can link luna as a drop-in for the covered surface: state lifecycle, value push/read, stack manipulation, the table API, lua_call / lua_pcall, and script load. A 13-test conformance suite pins it. Userdata, C-side coroutines, and continuations are not yet covered — use the richer Rust API for those.
Bytecode
luna emits per-dialect bytecode matching PUC's compiler binary format, so PUC-compiled .luac files load directly and luna-dumped bytecode loads in PUC. Bytecode loading is off by default in a sandbox — crafted bytecode can bypass the type checks the compiler enforces.
Architecture
Five publishable crates. The interpreter core carries zero third-party dependencies; the JIT plugs in through a trait, so swapping or removing the backend is a luna-jit concern that never touches luna-core's API.
| Crate | Depends on | Surface |
|---|---|---|
| luna-core | 0 third-party | Lexer, parser, compiler, interpreter, runtime, stdlib, GC, pattern engine, JIT trait |
| luna-jit-derive | syn + quote | #[derive(LuaUserdata)] proc-macro |
| luna-jit | luna-core + Cranelift ×6 | Cranelift backend, C ABI, luna CLI, embedding facade |
| luna-runtime-helpers | luna-jit | Static runtime entry for AOT binaries |
| luna-aot | luna-core + luna-jit | Build-time AOT compiler |
The zero-dep luna-core contract is enforced by cargo deny check in CI. Source is classified into three tiers — stone (business-agnostic foundations: pattern engine, heap, value layout), steel (Lua-domain primitives: compiler, dispatcher, JIT backend), and cement (host glue: CLI, C ABI, stdlib bindings) — each with its own change discipline and review depth.
Performance
luna deliberately does not publish a single headline ratio. A microbench cell that wins is a marketing artifact; the cell that loses is the signal worth chasing. Any gap greater than 1.5× a reference triggers a side-by-side, stage-by-stage decomposition of the workload — not surface-level polish.
What ships are reproducible baselines. Memory is profiled under dhat across five workloads; a >5% steady-state regression on any is a tracked alarm.
Versioning & stability
The public API is partitioned into a stable surface (breaking changes require a SemVer-major bump) and an unstable / internal surface (may change in a minor release for performance work).
- Stable: the luna_jit front-door types (Lua, LuaFunction, LuaTable, LuaRoot, LuaSandboxBuilder), the re-exported core (Vm, LuaVersion, Value, LuaError), the derive macros, and the lua.h-compatible C ABI.
- Unstable / internal: luna_core::{compiler, frontend, jit, pattern} and the JIT backend internals — free to change for optimization.
- Bytecode: the per-dialect binary format is stable; PUC .luac files load across the line.