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.

Cargo.toml
# 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).

main.rs
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}");
}
Which crate? luna-jit re-exports everything in luna-core, so you can start with the JIT crate and never think about the split. Reach for luna-core directly only when you want the tiny audit surface, a fast build, or a wasm32 target.

Command-line interface

Installing luna-jit puts a luna binary on your path — a REPL and script runner.

shell
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)
FlagBehavior
--lua=5.XSelect dialect (5.1 / 5.2 / 5.3 / 5.4 / 5.5; default 5.5)
--sandboxOpen base/math/string/table/coroutine only; reject bytecode loading
--budget=NSet an instruction budget before running
--no-jitInterpreter-only run (installs the null JIT backend)
--profilePrint 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.

sandbox.rs
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.

globals.rs
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.

natives.rs
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.

userdata.rs
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)
One unsound-to-break rule. If a userdata holds any Gc<…> field, you must override trace and m.mark(...) every handle — inside trace you may only mark, never call the Vm, allocate, or lock. A missing trace is a use-after-free. The derive macro handles this for you.

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.

coroutine.rs
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), terminal

Errors

Lua errors surface as Result<T, LuaError>. LuaError implements Display / Error; richer context is available from the Vm.

errors.rs
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.

facade.rs
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 TokioLocalSet::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.
pattern-1.rs
#[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(())
}
Opt-in SendVm. The feature = "send" flag adds a SendVm newtype that is Send (not Sync) so a clone can move across threads or be held across .await on a multi-thread runtime. It is interpreter-only today — the trace JIT does not run on a SendVm. Measured overhead is roughly within noise. For Send + JIT, use the Vm-per-thread pattern.

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.
Deliberate asymmetry. Safe-subset stdlibs (base, math, string, table, coroutine) are opened on the builder. OS-touching libraries (io/os, debug, package) require a post-build handle — vm.open_os_io() and friends — so the trust decision is always visible in host code. Budgets are unbounded until you arm them; the memory cap disarms after firing and must be re-armed to reuse a Vm across requests.

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.

ShapeCrateNotes
Standalone binary, frozen sourceluna-aotBuild-time; no runtime crate on host
Rust service, JIT enabledluna-jitPulls Cranelift; cross-thread via feature = "send"
Rust service, interpreter onlyluna-coreZero third-party deps
WASM (browser / wasmtime)luna-corewasm32-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.

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

KnobDefaultEffect
set_jit_enabled(false)trueDisable for predictable latency / debug repro
set_trace_jit_enabled(false)trueA/B the trace JIT against the interpreter
set_hot_threshold(n)constantLower for hot-immediately workloads
set_max_trace_len(n)constantRaise 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.

shell
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.linux

Cranelift'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:

LibraryMethodCoverage
baseopen_basefull
mathopen_mathfull
stringopen_stringfull (incl. pattern matching)
tableopen_tablefull
coroutineopen_coroutinefull
io / osopen_io / open_osfull (host-controlled)
utf8open_utf8full (5.3+)
debugpartial, 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.

CrateDepends onSurface
luna-core0 third-partyLexer, parser, compiler, interpreter, runtime, stdlib, GC, pattern engine, JIT trait
luna-jit-derivesyn + quote#[derive(LuaUserdata)] proc-macro
luna-jitluna-core + Cranelift ×6Cranelift backend, C ABI, luna CLI, embedding facade
luna-runtime-helpersluna-jitStatic runtime entry for AOT binaries
luna-aotluna-core + luna-jitBuild-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.

33KB
cold_start peak · 435 allocs
71KB
repl_idle · 100 evals
523KB
alloc_collect steady · 1M + 10 GC
63KB
userdata_lifecycle · 200 + finalizers

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.
Full stable/unstable item lists live in the embedding cookbook's API-contract section and in CHANGELOG.md. The rustdoc reference on docs.rs is the authoritative per-item source.