Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Documentation

Design and reference documentation for rilua, a Lua 5.1.1 interpreter in Rust.

Foundation

  1. architecture.md – Design principles, module structure, key decisions
  2. use-cases.md – WoW ecosystem and general embedding use cases
  3. references.md – Studied implementations and what we learned from each

API

  1. api.md – Public API: Lua struct, IntoLua/FromLua, handle types, embedding examples
  2. future-api.md – Planned API enhancements: closure-based functions, UserData trait, container conversions

Implementation

  1. features.md – Feature coverage and compatibility notes
  2. stdlib.md – All 9 standard libraries, function lists, implementation notes
  3. wasm.md – WebAssembly target: library availability, platform stubs, building for the browser

Quality

  1. testing.md – Unit tests, integration tests, PUC-Rio suite, behavioral equivalence
  2. performance.md – Profiling, benchmarks, regression gate, optimization history

Architecture Overview

rilua is a from-scratch implementation of Lua 5.1.1 in Rust. The goal is behavioral equivalence with the PUC-Rio reference interpreter — executed Lua code must produce identical results. Internal architecture is free to diverge where Rust idioms offer better safety, clarity, or modularity.

Design Principles

  1. Behavioral equivalence — Lua code produces the same results as PUC-Rio Lua 5.1.1. Observable behavior (output, errors, GC API returns, weak table clearing, finalizer execution) must match.
  2. Idiomatic Rust — Use Rust’s type system, ownership, and error handling. Minimize unsafe code. Prefer enums over tagged unions, Result over longjmp, traits over function pointers.
  3. Zero external dependencies — Only Rust’s standard library. All data structures, algorithms, and patterns are self-contained.
  4. Modular design — Clear module boundaries. The compiler does not depend on the VM. The GC does not depend on the compiler. Standard library functions are isolated from core VM logic.
  5. Spec-driven testing — Test against the Lua 5.1.1 specification and PUC-Rio’s official test suite. Unit tests for internals, integration tests for language semantics.

Pipeline

Source Code
    |
    v
 [Lexer]         src/compiler/lexer.rs
    |  tokens
    v
 [Parser]        src/compiler/parser.rs
    |  AST
    v
 [Compiler]      src/compiler/codegen.rs
    |  Proto (bytecode + constants + nested protos)
    v
 [VM]            src/vm/
    |  execution
    v
 Output / Side Effects

Unlike PUC-Rio’s single-pass compiler that emits bytecode during parsing, rilua uses an explicit AST intermediate representation. This follows the approach used by Luau (Roblox’s Lua 5.1-compatible scripting language).

Benefits of the AST phase:

  • Separation between parsing and code generation
  • Each phase is independently testable
  • Future optimizations (constant folding, dead code elimination) can operate on the AST without modifying the parser
  • Easier to understand and debug than interleaved parse-and-emit

Module Structure

src/
  lib.rs              Public API (Lua struct, traits, types)
  error.rs            Error types (syntax, runtime, argument)
  conversion.rs       IntoLua/FromLua trait implementations
  handles.rs          Table/Function/Thread/AnyUserData handle types
  platform.rs         Centralized FFI declarations (raw extern "C")
  bin/
    rilua.rs          Standalone interpreter (matches lua.c)
    riluac.rs         Bytecode compiler/lister (matches luac)
  compiler/
    mod.rs            Compiler module root
    lexer.rs          Tokenizer (source -> tokens)
    token.rs          Token types
    parser.rs         Parser (tokens -> AST)
    ast.rs            AST node types
    codegen.rs        Code generator (AST -> Proto)
  vm/
    mod.rs            VM module root
    state.rs          Lua state (the main VM struct)
    execute.rs        Instruction dispatch loop
    instructions.rs   Opcode definitions (Rust enums)
    proto.rs          Function prototype (bytecode container)
    value.rs          Value representation (Val enum)
    gc/
      mod.rs          GC module root
      arena.rs        Generational arena (typed Vec storage)
      collector.rs    Mark-sweep collector
      trace.rs        Trace trait for marking reachable objects
    table.rs          Table implementation (array + hash parts)
    string.rs         String interning
    closure.rs        Closures and upvalues
    callinfo.rs       Call stack (CallInfo chain)
    metatable.rs      Metamethod dispatch
    debug_info.rs     Debug info and variable name resolution
    dump.rs           Binary chunk serialization (string.dump)
    undump.rs         Binary chunk deserialization (loadstring)
    listing.rs        Bytecode listing (riluac -l output)
  stdlib/
    mod.rs            Standard library registration
    base.rs           Base library (print, assert, type, etc.)
    coroutine.rs      Coroutine library
    string.rs         String library
    table.rs          Table library
    math.rs           Math library
    io.rs             I/O library
    os.rs             OS library
    debug.rs          Debug library
    package.rs        Package/module library
    testlib.rs        T test module (PUC-Rio ltests.c equivalent)

Key Architectural Decisions

DecisionChoiceRationale
Compilation pipelineLexer -> Parser -> AST -> CompilerSeparation of concerns, testability
Instruction setPUC-Rio’s 38 opcodes as Rust enumsBehavioral equivalence, type safety
Value representationRust enum (Val)Type safety, pattern matching
Garbage collectionArena with generational indicesZero unsafe, mark-sweep
TablesArray + hash dual representationPerformance, PUC-Rio compatibility
StringsInterned with cached hashPointer equality, O(1) comparison
Closures and upvaluesOpen/closed upvalue modelPUC-Rio semantics
Error handlingResult-basedIdiomatic Rust, no longjmp
Public APITrait-based, Rust-idiomaticErgonomic embedding (api.md)
Standard libraryModular, per-library filesIndependent testing, optional loading (stdlib.md)
Call stackDynamic CallInfo arraySeparate from value stack, index-based
MetatablesPUC-Rio 5.1.1 dispatch semantics17 metamethods, type coercion rules
CoroutinesThreads with shared GC heapIndependent stacks, cooperative multithreading
Testing strategySpec-driven, multi-layerCorrectness assurance (testing.md)
Platform abstractionCentralized FFI with WASM stubsCross-platform without conditional code in consumers (wasm.md)

Platform Support

All C FFI is centralized in src/platform.rs. See wasm.md for WASM-specific stubs and library availability.

Supported targets:

TargetStatusNotes
x86_64-unknown-linux-gnuFullPrimary development platform
x86_64-apple-darwin / aarch64-apple-darwinFullmacOS (Intel + Apple Silicon)
x86_64-pc-windows-msvcFullMSVC toolchain, links ucrt
wasm32-unknown-unknownCoreNo I/O/OS; see wasm.md

Reference Implementations

See references.md for a classification of all studied implementations and what we learned from each.

Use Cases

Overview

rilua targets two audiences: the World of Warcraft emulation community and Rust developers who need an embeddable Lua 5.1.1 interpreter. The WoW use cases drive the project’s priorities. General embedding is a secondary benefit of the architecture.

Primary: World of Warcraft

Addon development and testing

Run and test WoW addons outside the game client. Addon authors can validate Lua logic, check for errors, and iterate without launching WoW. This requires:

  • Lua 5.1.1 behavioral equivalence (the client’s Lua version)
  • WoW-specific global aliases (strtrim, strsplit, strjoin, wipe, tinsert, tremove, degree-based trig functions)
  • WoW-specific string.format positional arguments (%2$d)
  • TOC file parsing for addon manifest loading
  • Stub implementations of the WoW API (frame system, events, CVars)

The last two items are out of scope for rilua itself but enabled by the embedding API. A separate WoW environment layer (analogous to wowbench) can be built on top.

Server-side scripting

Private server emulators (CMaNGOS, TrinityCore, AzerothCore) embed a Lua interpreter for scripted content: boss encounters, quests, NPC behavior, world events. rilua can serve as an alternative to the bundled Lua or Eluna scripting engines. This requires:

  • The embedding API (Lua::new(), register_function, UserData)
  • Reliable error handling (scripts must not crash the server)
  • Controlled GC (server processes are long-lived)

Client Lua environment emulation

Reproduce the WoW client’s Lua sandbox for compatibility testing and emulation research. The WoW client runs a modified Lua 5.1.1 with:

  • Restricted stdlib: no io library, no os.execute, no binary module loading, limited debug library
  • Taint system: tracks whether code originated from Blizzard (“secure”) or addons (“insecure”). Tainted code cannot perform protected actions (casting spells, using items). Taint propagates through reads, writes, and function calls.
  • Modified GC defaults: gcpause=110 (more aggressive than the standard 200), maxcstack=4096 (doubled from standard 2048)
  • Removed Lua 5.0 compatibility: no LUA_COMPAT_VARARG, LUA_COMPAT_MOD, LUA_COMPAT_LSTR, LUA_COMPAT_GFIND
  • Bitwise operations library: bit.band, bit.bor, bit.bxor, bit.bnot, bit.lshift, bit.rshift, bit.arshift, bit.mod (32-bit integer operations)
  • UTF-8 BOM handling: automatically strips byte order marks

The taint system is a significant extension. It requires per-object and per-value taint tracking with propagation modes (read, write, both, disabled). This is a post-1.0 feature, documented separately in elune’s implementation.

Addon compatibility testing

Automated verification that addons behave correctly. A test harness loads addons via their TOC manifests and runs them against a mock WoW environment, checking for:

  • Runtime errors
  • Taint violations
  • Correct event handling
  • Saved variable serialization

This is the wowbench use case. rilua provides the interpreter; the test harness and WoW API stubs are a separate layer.

Secondary: General Lua 5.1.1

Embedded scripting for Rust applications

The trait-based API (IntoLua/FromLua, UserData, Function) makes rilua usable as a general-purpose embedded scripting language for Rust programs. Use cases include:

  • Game engines (configuration, modding, entity scripting)
  • Command-line tools with user-configurable behavior
  • Applications that need a sandboxed extension language
  • No transitive dependencies (see architecture.md)

Lua 5.1.1 conformance reference

The PUC-Rio C source (lua-5.1.1) is the authoritative implementation but difficult to read. rilua provides an alternative reference implementation in Rust with:

  • Named types and enums instead of tagged unions and macros
  • Explicit control flow instead of longjmp-based error handling
  • Pattern matching instead of switch-case fallthrough
  • Module boundaries instead of translation-unit scoping

Useful for anyone studying Lua internals or building their own implementation.

Educational use

rilua demonstrates language implementation techniques in a real project:

  • Lexing and tokenization
  • Recursive descent parsing with Pratt operator precedence
  • AST-based compilation to register bytecode
  • Register allocation and jump backpatching
  • Mark-sweep garbage collection with weak references
  • Closure capture with open/closed upvalue conversion
  • Coroutines via separate thread stacks

The design documentation in docs/ covers each subsystem with algorithms sourced from PUC-Rio’s C code and translated into Rust patterns.

Out of Scope

These are explicitly not goals:

  • General-purpose Lua replacement: Luau, LuaJIT, and PUC-Rio 5.4 serve this better
  • Performance-critical production scripting: no JIT, no optimized GC; if throughput matters, use LuaJIT
  • Lua 5.2+ features: goto, integer subtype, bitwise operators (native), generalized for, _ENV – all out of scope
  • Binary compatibility with PUC-Rio: the C ABI, lua_State* layout, and bytecode format are intentionally different

Reference Implementations

Classification of implementations studied during architecture design. Each was examined for its approach to compilation, VM design, memory management, and API surface.

Tier 1 — Primary References

PUC-Rio Lua 5.1.1 (C)

  • Source: ./lua-5.1.1/ (vendored in repo; see AGENTS.md for setup)
  • Role: The specification. All behavioral questions are answered here.
  • Architecture: 17,654 lines. Single-pass recursive descent compiler emitting 38 register-based opcodes (u32 packed). CallInfo chain for call stack. Incremental tri-color mark-sweep GC with write barriers. Array+hash tables. Interned strings with cached hash. longjmp/setjmp error handling. Stack-based C API.
  • What we take: Opcode semantics, GC behavioral contract, weak table semantics, finalizer protocol, collectgarbage() API, standard library behavior, error messages.
  • What we change: Single-pass compilation (we use AST), longjmp (we use Result), C API (we use traits), raw pointers (we use arenas).

Luau (C++)

  • GitHub: https://github.com/luau-lang/luau
  • Role: Architecture reference. Lua 5.1-compatible scripting language.
  • Architecture: Lexer -> Parser -> AST -> Compiler -> VM. 83 register-based opcodes (count evolves with development). Incremental tri-color GC. CallInfo chain. Array+hash tables. String interning. No longjmp (C++ exceptions).
  • What we take: AST-based pipeline design, separation of compiler phases, Proto as non-GC value (owned by closures), CallInfo chain pattern, incremental GC debt model.
  • What we change: Opcode count (we use PUC-Rio’s 38, not Luau’s 83), language extensions (we implement standard Lua 5.1.1 only), native codegen (out of scope).

Tier 2 — Selective References

tsuki (Rust, Lua 5.4)

  • GitHub: https://github.com/nickmass/tsuki
  • Role: Proves Result-based error handling works for a Lua VM.
  • Architecture: c2rust translation of Lua 5.4. PUC-Rio file naming (llex.rs, lparser.rs, lcode.rs). 83 opcodes. Incremental GC via raw pointers. Heavy use of unsafe code. Memory-safe public API over unsafe internals.
  • What we take: Result-based error propagation pattern, memory-safe public API design, packed u32 instruction format for serialization.
  • What we avoid: c2rust code style, heavy unsafe, wrong Lua version (5.4 vs 5.1.1).

full-moon (Rust, parser only)

  • GitHub: https://github.com/Kampfkarren/full-moon
  • Role: Best Rust patterns for Lua parsing.
  • Architecture: Hand-written recursive descent parser. Lossless AST preserving whitespace and comments. Error recovery with partial AST. Multi-dialect support (5.1-5.4, Luau) via feature flags. Sealed traits, visitor pattern, builder pattern.
  • What we take: Recursive descent parsing patterns, AST node design idioms, error recovery approach, sealed trait pattern.
  • What we avoid: Lossless parsing (unnecessary for a VM), multi-dialect support (we only target 5.1.1), external dependencies.

mlua (Rust, FFI wrapper)

  • GitHub: https://github.com/mlua-rs/mlua
  • Role: API design reference for embedding Lua in Rust.
  • Architecture: FFI wrapper around C Lua. Memory-safe Rust API over unsafe C bindings. Trait-based type conversions (IntoLua, FromLua). Registry-based lifetime management. Scoped values. UserData trait.
  • What we take: Trait-based API design (IntoLua/FromLua pattern), UserData trait approach, scope-based lifetime management, error type design.
  • Not applicable: FFI wrapping (we implement from scratch), C Lua compilation, vendored builds.

lua-rs / CppCXY (Rust, Lua 5.5)

  • GitHub: https://github.com/CppCXY/lua-rs
  • Role: Architecture comparison. Full Lua 5.5 port to Rust with pointer-based VM design.
  • Architecture: 64k lines across workspace (luars, luars-derive, luars_interpreter, luars_wasm). Faithful port of C Lua’s architecture: pointer-based VM dispatch, 86 Lua 5.5 opcodes, tri-color incremental + generational GC, 16-byte TValue (union + type tag), Brent’s collision tables, interned strings, pointer-based upvalues. 399 unsafe blocks. Multi-crate workspace with proc-macro derive crate. 28/30 official Lua 5.5 tests pass. External dependencies: ahash, rand, chrono, itoa, smol_str, syn/quote (macros).
  • What we can study: Async/await coroutine bridging (novel feature), proc-macro derive for UserData, generational GC implementation, Lua 5.5 opcode set, lightweight 1-byte error enum with message stored in VM, platform abstraction layer design, WASM support approach.
  • Key differences from rilua: Targets Lua 5.5 (not 5.1.1), uses raw pointers and union types (C-style, 399 unsafe blocks vs rilua’s arena-based zero-unsafe GC), has external dependencies (ahash, rand, chrono, smol_str vs rilua’s zero-dependency policy), pointer-based upvalues (vs rilua’s arena indices), larger scope (64k LOC vs rilua’s ~17k), proc-macro crate for ergonomic API.

Tier 3 — Limited Relevance

lua-in-rust (Rust, Lua 5.1.1)

  • GitHub: https://github.com/cjneidhart/lua-in-rust
  • Role: Previous base for rilua. Studied for lessons learned.
  • Architecture: Single-pass compiler, stack-based VM (~40 custom opcodes), stop-the-world mark-sweep GC with raw pointers, string interning, HashMap-only tables. ~5,000 lines. ~40% complete.
  • Lessons: Stack-based VM diverges too far from PUC-Rio’s register-based design. Single-pass compilation makes the compiler hard to test and extend. Raw pointer GC works but is fragile. String interning with pointer equality is the right approach.

lua-rs / lonng (Rust, compiler only)

  • GitHub: https://github.com/lonng/lua-rs
  • Role: Shows AST-to-bytecode compilation for Lua 5.1.
  • Architecture: Multi-pass with explicit AST. Scanner -> Parser -> AST -> Compiler -> FunctionProto. No VM. Broken build. Zero unsafe.
  • Useful for: AST node design, constant folding patterns, debug info tracking. Not useful for VM or runtime design.

lua-rs (Rust, full interpreter, CppCXY)

  • Local path: ~/Repos/github.com/CppCXY/lua-rs
  • GitHub: https://github.com/CppCXY/lua-rs
  • Role: Performance comparison target. One-to-one C Lua 5.5 port.
  • Architecture: Direct port of PUC-Rio’s C source to Rust. Same pipeline (Lexer -> Parser -> Code Generator -> Bytecode -> VM), 86 opcodes, tri-color incremental/generational GC, string interning. ~60K lines, ~400 unsafe blocks for GC pointer ops. 5 runtime deps (ahash, rand, chrono, itoa, smol_str). Requires nightly Rust.
  • Useful for: Benchmarking (runs ~1.2x PUC-Rio on micro-benchmarks), comparing pure-Rust Lua implementation approaches and the safety-vs-performance trade-off.
  • Differences: Targets Lua 5.5 (not 5.1.1), uses unsafe extensively, requires nightly. See docs/src/comparison.md.

coppermoon (Rust, mlua wrapper)

  • GitHub: https://github.com/coppermoondev/coppermoon
  • Role: None for VM/compiler work.
  • Architecture: Node.js-like runtime wrapping mlua (C Lua 5.4 FFI). Not a from-scratch implementation. Provides batteries-included stdlib (HTTP, SQLite, WebSocket, etc.) over the C VM.
  • Not applicable: Everything. This wraps C Lua, not implements it.

hematita (Rust, Lua 5.3)

  • GitHub: https://github.com/danii/hematita
  • Role: Reference for hardened Lua interpreter patterns in Rust.
  • Architecture: From-scratch Lua interpreter in Rust targeting 5.3. Focuses on safety and correctness. Useful for comparing implementation approaches for parsing, value representation, and standard library.
  • What we take: Comparison point for implementation patterns and edge case handling.
  • Differences: Targets Lua 5.3 (not 5.1.1), different design goals.

Public API

Decision

Rust-idiomatic, trait-based API inspired by mlua’s design. Not a 1:1 mirror of the Lua C API.

Overview

PUC-Rio Lua exposes a stack-based C API where values are pushed and popped from a virtual stack. This works well in C but is unergonomic in Rust – it lacks type safety, requires manual stack management, and does not leverage Rust’s trait system. rilua uses traits for type conversion, methods for common operations, and the type system for safety instead.

See future-api.md for planned ergonomic improvements.

Core Type: Lua

/// A Lua interpreter instance.
///
/// Owns the full VM state including value stack, call stack, GC heap,
/// and global table. All interaction with Lua values goes through this
/// struct.
pub struct Lua {
    // Internal state (not public)
}

impl Lua {
    /// Create a new Lua state with all standard libraries loaded.
    pub fn new() -> LuaResult<Self> { ... }

    /// Create a new Lua state without any libraries.
    pub fn new_empty() -> Self { ... }

    /// Create a new Lua state with selected standard libraries.
    ///
    /// ```ignore
    /// let lua = Lua::new_with(StdLib::BASE | StdLib::STRING)?;
    /// ```
    pub fn new_with(libs: StdLib) -> LuaResult<Self> { ... }

    // -- Execution --

    /// Compile and execute a Lua source string.
    /// Chunk name is set to `"=(string)"`.
    pub fn exec(&mut self, source: &str) -> LuaResult<()> { ... }

    /// Compile and execute Lua source bytes with a given chunk name.
    /// Source is `&[u8]` because Lua files may contain arbitrary bytes.
    pub fn exec_bytes(
        &mut self, source: &[u8], name: &str,
    ) -> LuaResult<()> { ... }

    /// Read a file and execute its contents as a Lua chunk.
    /// Chunk name is set to `@<path>`.
    pub fn exec_file(&mut self, path: &str) -> LuaResult<()> { ... }

    /// Compile a Lua source string and return a function handle.
    /// Chunk name is set to `"=(string)"`.
    pub fn load(&mut self, source: &str) -> LuaResult<Function> { ... }

    /// Compile Lua source bytes (or load a binary chunk) and return
    /// a function handle.
    pub fn load_bytes(
        &mut self, source: &[u8], name: &str,
    ) -> LuaResult<Function> { ... }

    /// Read a file (or stdin if `None`) and compile it, returning a
    /// function handle. Handles shebang lines in executable scripts.
    pub fn load_file(
        &mut self, path: Option<&str>,
    ) -> LuaResult<Function> { ... }

    // -- Globals --

    /// Get a global variable, converting via FromLua.
    /// Takes `&mut self` because looking up a string key may intern
    /// the name.
    pub fn global<V: FromLua>(
        &mut self, name: &str,
    ) -> LuaResult<V> { ... }

    /// Set a global variable from a Rust value, converting via IntoLua.
    pub fn set_global<V: IntoLua>(
        &mut self, name: &str, value: V,
    ) -> LuaResult<()> { ... }

    // -- Object creation --

    /// Allocate a new empty table and return a handle.
    pub fn create_table(&mut self) -> Table { ... }

    /// Intern a byte string via the GC string table.
    pub fn create_string(&mut self, s: &[u8]) -> Val { ... }

    /// Register a Rust function as a global Lua function.
    /// See [Implementing Native Functions](#implementing-native-functions)
    /// for the `RustFn` signature and usage.
    pub fn register_function(
        &mut self, name: &str, func: RustFn,
    ) -> LuaResult<()> { ... }

    // -- Userdata creation --

    /// Create a new userdata containing `data` with no metatable.
    pub fn create_userdata<T: Any>(&mut self, data: T) -> AnyUserData { ... }

    /// Create a new userdata with a named, registry-cached metatable.
    /// The metatable is stored in the registry under `type_name` and
    /// reused for subsequent calls with the same name.
    pub fn create_typed_userdata<T: Any>(
        &mut self, data: T, type_name: &str,
    ) -> LuaResult<AnyUserData> { ... }

    /// Create or retrieve a named metatable for a userdata type.
    pub fn create_userdata_metatable(
        &mut self, type_name: &str,
    ) -> LuaResult<Table> { ... }

    // -- Calling functions --

    /// Call a loaded Function handle with arguments and collect results.
    ///
    /// Arguments and results are raw `Val` values. For type-safe
    /// conversions, use `IntoLua`/`FromLua` on individual values.
    pub fn call_function(
        &mut self, func: &Function, args: &[Val],
    ) -> LuaResult<Vec<Val>> { ... }

    /// Call a loaded Function handle, appending a stack traceback
    /// on error. Used by the CLI to match PUC-Rio's `docall` pattern.
    pub fn call_function_traced(
        &mut self, func: &Function, args: &[Val],
    ) -> LuaResult<Vec<Val>> { ... }

    // -- Table operations --

    /// Raw set on a table handle (no metamethod dispatch).
    pub fn table_raw_set(
        &mut self, table: &Table, key: Val, value: Val,
    ) -> LuaResult<()> { ... }

    // -- GC control --

    /// Run a full garbage collection cycle.
    pub fn gc_collect(&mut self) -> LuaResult<()> { ... }

    /// Get current memory usage in bytes.
    pub fn gc_count(&self) -> usize { ... }

    /// Stop automatic garbage collection.
    pub fn gc_stop(&mut self) { ... }

    /// Restart automatic garbage collection.
    pub fn gc_restart(&mut self) { ... }

    /// Perform an incremental GC step. Returns true if a cycle completed.
    pub fn gc_step(&mut self, step_size: i64) -> LuaResult<bool> { ... }

    /// Set GC pause parameter (percentage). Returns the previous value.
    pub fn gc_set_pause(&mut self, pause: u32) -> u32 { ... }

    /// Set GC step multiplier. Returns the previous value.
    pub fn gc_set_step_multiplier(&mut self, stepmul: u32) -> u32 { ... }
}

Selective Library Loading

bitflags! {
    pub struct StdLib: u16 {
        const BASE      = 0x0001;
        const PACKAGE   = 0x0002;
        const TABLE     = 0x0004;
        const IO        = 0x0008;
        const OS        = 0x0010;
        const STRING    = 0x0020;
        const MATH      = 0x0040;
        const DEBUG     = 0x0080;
        const COROUTINE = 0x0100;
        const ALL       = 0x01FF;
    }
}

Use Lua::new_with(StdLib::BASE | StdLib::STRING) to load only selected libraries. This enables sandboxing by excluding dangerous libraries (IO, OS, debug).

Conversion Traits

IntoLua / FromLua

/// Convert a Rust value into a Lua value.
pub trait IntoLua {
    fn into_lua(self, lua: &mut Lua) -> LuaResult<Val>;
}

/// Convert a Lua value into a Rust value.
pub trait FromLua: Sized {
    fn from_lua(val: Val, lua: &Lua) -> LuaResult<Self>;
}

Implemented for:

Rust TypeLua TypeDirection
()nilboth
boolbooleanboth
f64, f32numberboth
i8..i64, u8..u64number (with range check)both
Stringstringboth
&strstringIntoLua only
&[u8]stringIntoLua only
Vec<u8>string (raw bytes)FromLua only
Val(any)both (passthrough)
Tabletableboth
Functionfunctionboth
Threadthreadboth
AnyUserDatauserdataboth

IntoLuaMulti / FromLuaMulti

For functions with multiple arguments or return values:

pub trait IntoLuaMulti {
    fn into_lua_multi(self, lua: &mut Lua) -> LuaResult<Vec<Val>>;
}

pub trait FromLuaMulti: Sized {
    fn from_lua_multi(values: &[Val], lua: &Lua) -> LuaResult<Self>;
}

Implemented for Vec<Val> (variable-length) and () (zero values).

Handle Types

Table

pub struct Table(/* GcRef<table::Table> */);

impl Table {
    /// Get a value by key (raw, no metamethods).
    pub fn raw_get(
        &self, state: &LuaState, key: Val,
    ) -> LuaResult<Val> { ... }

    /// Set a value by key (raw, no metamethods).
    pub fn raw_set(
        &self, state: &mut LuaState, key: Val, value: Val,
    ) -> LuaResult<()> { ... }

    /// Raw length (no `__len` metamethod).
    pub fn raw_len(&self, state: &LuaState) -> i64 { ... }

    /// Set or clear the metatable.
    pub fn set_metatable(
        &self, state: &mut LuaState, mt: Option<Table>,
    ) -> LuaResult<()> { ... }

    /// Get the underlying GcRef.
    pub fn gc_ref(self) -> GcRef<table::Table> { ... }
}

Note: Table handle methods take &LuaState (the internal VM state), not &Lua. This is because handles are used both from the public API and from stdlib internals. Use lua.state() / lua.state_mut() (which are pub(crate)) to get the state reference, or use Lua::table_raw_set() for the public-facing convenience method.

Function

pub struct Function(/* GcRef<Closure> */);

impl Function {
    /// Get the underlying GcRef.
    pub fn gc_ref(self) -> GcRef<Closure> { ... }
}

Call a Function via Lua::call_function() or Lua::call_function_traced().

Thread

pub struct Thread(/* GcRef<LuaThread> */);

impl Thread {
    /// Get the status of this coroutine thread.
    pub fn status(&self, state: &LuaState) -> ThreadStatus { ... }

    /// Get the underlying GcRef.
    pub fn gc_ref(self) -> GcRef<LuaThread> { ... }
}

pub enum ThreadStatus {
    Initial,    // loaded, not yet started
    Running,    // currently executing
    Suspended,  // yielded, waiting to be resumed
    Normal,     // resumed another coroutine, waiting
    Dead,       // finished or errored
}

AnyUserData

pub struct AnyUserData(/* GcRef<Userdata> */);

impl AnyUserData {
    /// Borrow the inner data as `&T`.
    /// Returns None if the type doesn't match or the userdata was collected.
    pub fn borrow<'a, T: Any>(
        &self, state: &'a LuaState,
    ) -> Option<&'a T> { ... }

    /// Borrow the inner data as `&mut T`.
    pub fn borrow_mut<'a, T: Any>(
        &self, state: &'a mut LuaState,
    ) -> Option<&'a mut T> { ... }

    /// Set or clear the metatable.
    pub fn set_metatable(
        &self, state: &mut LuaState, mt: Option<Table>,
    ) -> LuaResult<()> { ... }

    /// Get the metatable, if set.
    pub fn metatable(&self, state: &LuaState) -> Option<Table> { ... }

    /// Get the underlying GcRef.
    pub fn gc_ref(self) -> GcRef<Userdata> { ... }
}

Embedding Example

Mirrored at examples/embedding.rs.

use rilua::{Lua, LuaApiMut, StdLib, Val};

fn main() -> rilua::LuaResult<()> {
    let mut lua = Lua::new_with(StdLib::ALL)?;

    // Execute Lua code
    lua.exec(r#"
        x = 1 + 2
        msg = string.format("x = %d", x)
    "#)?;

    // Read Lua globals from Rust
    let x: f64 = lua.global("x")?;
    assert_eq!(x, 3.0);

    let msg: String = lua.global("msg")?;
    assert_eq!(msg, "x = 3");

    // Set Lua globals from Rust
    lua.set_global("greeting", "hello from Rust")?;
    lua.exec("print(greeting)")?;

    // Load and call a function
    let func = lua.load("return 1 + 2")?;
    let results = lua.call_function(&func, &[])?;
    assert_eq!(results, vec![Val::Num(3.0)]);

    Ok(())
}

Implementing Native Functions

Native functions use the low-level stack-based API via LuaState. This is the same API used by the standard library implementation.

Mirrored at examples/native_function.rs.

use rilua::vm::state::LuaState;
use rilua::LuaResult;
use rilua::{Lua, LuaApiMut, RustFn};

/// A native function that adds two numbers.
/// Arguments are on the stack at indices base..top.
/// Returns the number of results pushed.
fn my_add(state: &mut LuaState) -> LuaResult<u32> {
    let a = state.check_number(1)?;
    let b = state.check_number(2)?;
    state.push_number(a + b);
    Ok(1)
}

fn main() -> rilua::LuaResult<()> {
    let mut lua = Lua::new()?;
    let f: RustFn = my_add;
    lua.register_function("my_add", f)?;
    lua.exec("print(my_add(10, 20))")?; // prints 30
    Ok(())
}

The RustFn type is fn(&mut LuaState) -> LuaResult<u32>. It takes a function pointer, not a closure. For stateful native functions, use RustClosure upvalues (see future-api.md for a planned closure-based alternative).

Internal Stack Model

Internally, rilua uses a virtual stack similar to PUC-Rio’s C API for stdlib function implementation. Understanding this model is necessary for implementing stdlib functions and the debug library.

Stack Index Addressing

Stack indices address values relative to the current call frame:

Index typeRangeResolution
Positive1, 2, 3, …Base-relative: base + index - 1
Negative-1, -2, -3, …Top-relative: top + index
Pseudo-indexspecial constantsRegistry, globals, environment, upvalues

Pseudo-indices provide access to non-stack locations:

Pseudo-indexTarget
REGISTRY_INDEXGlobal registry table (shared across all threads)
GLOBALS_INDEXCurrent thread’s global table
ENVIRON_INDEXCurrent function’s environment table
Upvalue indicesC closure upvalue slots (one per captured value)

Push/Get Protocol

Push operations write to stack[top] then increment top. Operations that allocate GC objects (strings, tables, closures) trigger a GC check. Operations on immediate values (nil, boolean, number) do not.

Get operations read from the addressed stack slot. Most are non-mutating. Exception: number-to-string conversion mutates the stack slot in place and triggers a GC check (string allocation).

C closure upvalues are stored as values directly inside the closure struct (not as UpVal objects like Lua closures). When creating a C closure with n upvalues, the top n stack values are popped and copied into the closure’s upvalue array.

Table Operations

With metamethods (gettable/settable): invoke the full __index/__newindex chain (up to 100 iterations). gettable takes the key from the stack top, replaces it with the result. settable takes key and value from the top, pops both.

Without metamethods (rawget/rawset): bypass the metamethod chain. Call luaH_get/luaH_set directly. The raw variants require the target to be a table (not just anything with a metatable).

Integer shortcuts (rawgeti/rawseti): take the integer key as a parameter instead of from the stack.

Call Protocol

  1. Push the function onto the stack.
  2. Push arguments in order (first argument pushed first).
  3. Call with (nargs, nresults).
  4. The function and all arguments are removed.
  5. nresults results are pushed (excess discarded, missing padded with nil). MULTRET (-1) means push all results.

Protected calls return a status code. On error, the function and arguments are replaced by a single error message on the stack. An optional error handler function is specified by stack index (converted to a stack offset internally because the stack may be reallocated).

Registry and Environments

The registry is a global table stored in the shared state. It is accessible via the registry pseudo-index. C libraries use it to store private data (metatables, module state, references) that should not be accessible from Lua code.

Function environments: every closure (C or Lua) has an associated environment table. For Lua closures, the environment is the table used for global variable lookups. The environment is captured from the creating function when a new closure is created.

Thread environments: each thread has its own global table. The main thread’s global table is the default environment for new closures.

Reference System

The reference system provides a way to store Lua values in a table (typically the registry) and retrieve them later by integer handle. It is a free-list allocator using integer keys:

  • ref(table) pops a value from the stack, stores it at an integer key. If there is a free slot (from a previous unref), it reuses it. Otherwise appends at #table + 1.
  • unref(table, ref) adds the slot back to the free list.
  • table[0] stores the free list head. Each free slot stores the index of the next free slot as its value.
  • Special constants: REF_NIL (-1) for nil values (never stored), NO_REF (-2) for invalid references.

Library Registration Protocol

Each standard library is loaded by pushing its opener function, pushing the library name as argument, then calling. The opener:

  1. Creates a table for the library (or reuses an existing one from _LOADED).
  2. Registers all functions via closure creation and field assignment.
  3. Stores the table in both _LOADED[name] and as a global.

The base library uses an empty name and registers directly into the global table.

GC Handle Safety

Values on the Lua stack (between stack[0] and stack[top-1]) are marked as reachable during GC traversal. This is the primary mechanism for protecting values from collection.

Stack traversal during GC:

  1. Mark the thread’s global table.
  2. Mark all values from stack[0] to stack[top-1].
  3. Nil out slots from top to the maximum ci.top across all call frames (clears stale references).

GC checks (checkGC) run after operations that allocate GC objects. The check occurs before the allocation in the API function, which is safe because the new object does not exist yet. After allocation, the object is immediately placed on the stack (making it reachable).

Write barriers maintain the tri-color invariant during incremental GC:

  • Forward barrier: when a black object (already traversed) gets a white value (not yet traversed) stored into it, the white value is immediately marked.
  • Back barrier (for tables): the table is re-grayed so it will be re-traversed.

C closure upvalues and table entries both require write barriers when mutated through the API.

Future API Enhancements

Planned ergonomic improvements for the rilua embedding API. These are not yet implemented. See docs/api.md for the current API.

Closure-Based Function Creation

Currently, native functions must be declared as fn pointers (fn(&mut LuaState) -> LuaResult<u32>). This prevents capturing state. A closure-based API would allow registering Rust closures directly:

impl Lua {
    /// Create a Lua-callable function from a Rust closure.
    pub fn create_function<F>(&mut self, func: F) -> LuaResult<Function>
    where
        F: Fn(&mut Lua) -> LuaResult<u32> + 'static,
    { ... }
}

This requires storing a Box<dyn Fn> inside the closure rather than a bare function pointer. The current RustClosure struct uses upvalue slots for state, which works but is less ergonomic for embedders.

Trait-Based Call and Resume

Function calling and coroutine resuming currently use raw Val slices. Trait-based versions would automatically convert arguments and results:

impl Lua {
    /// Call a Lua function with trait-based argument/result conversion.
    pub fn call<A, R>(&mut self, func: Function, args: A) -> LuaResult<R>
    where
        A: IntoLuaMulti,
        R: FromLuaMulti,
    { ... }

    /// Protected call -- catches Lua errors.
    pub fn pcall<A, R>(
        &mut self, func: Function, args: A,
    ) -> std::result::Result<R, LuaError>
    where
        A: IntoLuaMulti,
        R: FromLuaMulti,
    { ... }

    /// Create a new coroutine from a function.
    pub fn create_thread(
        &mut self, func: Function,
    ) -> LuaResult<Thread> { ... }
}

impl Thread {
    pub fn resume<A: IntoLuaMulti, R: FromLuaMulti>(
        &self, lua: &mut Lua, args: A,
    ) -> LuaResult<R> { ... }
}

impl Function {
    pub fn call<A: IntoLuaMulti, R: FromLuaMulti>(
        &self, lua: &mut Lua, args: A,
    ) -> LuaResult<R> { ... }
}

This depends on tuple impls for IntoLuaMulti / FromLuaMulti (see below).

Tuple Multi-Value Conversions

Currently IntoLuaMulti and FromLuaMulti are only implemented for Vec<Val> and (). Tuple impls would enable ergonomic multi-argument and multi-return patterns:

// Implemented for tuples up to reasonable arity:
impl<A: IntoLua, B: IntoLua> IntoLuaMulti for (A, B) { ... }
impl<A: FromLua, B: FromLua> FromLuaMulti for (A, B) { ... }
// ... up to 8 or 12 elements

This enables patterns like:

let (name, age): (String, f64) = lua.call(func, ("query", 42))?;

Container Conversions

Additional IntoLua / FromLua implementations for standard Rust container types:

Rust TypeLua TypeNotes
Vec<T>table (sequence)Keys are 1-indexed integers
HashMap<K, V>tableKey and value types must implement traits
Option<T>T or nilNone maps to nil, Some(v) maps to v
impl<T: IntoLua> IntoLua for Vec<T> { ... }
impl<T: FromLua> FromLua for Vec<T> { ... }

impl<K: IntoLua + Eq + Hash, V: IntoLua> IntoLua for HashMap<K, V> { ... }
impl<K: FromLua + Eq + Hash, V: FromLua> FromLua for HashMap<K, V> { ... }

impl<T: IntoLua> IntoLua for Option<T> { ... }
impl<T: FromLua> FromLua for Option<T> { ... }

Trait-Based Table Access

Table handles currently only support raw access with Val keys. Trait-based accessors would add type conversion and metamethod support:

impl Table {
    /// Get with metamethods and type conversion.
    pub fn get<K: IntoLua, V: FromLua>(
        &self, lua: &mut Lua, key: K,
    ) -> LuaResult<V> { ... }

    /// Set with metamethods and type conversion.
    pub fn set<K: IntoLua, V: IntoLua>(
        &self, lua: &mut Lua, key: K, value: V,
    ) -> LuaResult<()> { ... }

    /// Length with `__len` metamethod.
    pub fn len(&self, lua: &mut Lua) -> LuaResult<i64> { ... }

    /// Iterate key-value pairs (wraps `next()`).
    pub fn pairs<K: FromLua, V: FromLua>(
        &self, lua: &mut Lua,
    ) -> TablePairs<K, V> { ... }

    /// Get the next key-value pair after `key`.
    pub fn next<K: IntoLua, NK: FromLua, NV: FromLua>(
        &self, lua: &mut Lua, key: K,
    ) -> LuaResult<Option<(NK, NV)>> { ... }
}

Note: the current raw methods take &LuaState. The trait-based versions would take &mut Lua to support string interning and metamethod dispatch.

UserData Trait

The current userdata system uses Box<dyn Any> with manual metatable construction. An mlua-style UserData trait would allow declarative method and field registration:

pub trait UserData {
    fn add_methods(methods: &mut UserDataMethods<Self>);
    fn add_fields(fields: &mut UserDataFields<Self>);
}

struct UserDataMethods<T> { ... }

impl<T> UserDataMethods<T> {
    pub fn add_method<M, A, R>(&mut self, name: &str, method: M)
    where
        M: Fn(&T, &mut Lua, A) -> LuaResult<R>,
        A: FromLuaMulti,
        R: IntoLuaMulti,
    { ... }

    pub fn add_method_mut<M, A, R>(&mut self, name: &str, method: M)
    where
        M: FnMut(&mut T, &mut Lua, A) -> LuaResult<R>,
        A: FromLuaMulti,
        R: IntoLuaMulti,
    { ... }
}

Usage:

struct Player { name: String, health: f64 }

impl UserData for Player {
    fn add_methods(methods: &mut UserDataMethods<Self>) {
        methods.add_method("name", |p, _, ()| Ok(p.name.clone()));
        methods.add_method_mut("heal", |p, _, amount: f64| {
            p.health += amount;
            Ok(())
        });
    }
}

This depends on closure-based function creation and tuple multi-value conversions.

Native Function Helpers

Currently, native functions interact with the stack directly via LuaState methods (check_number, check_string, etc.). Higher-level helpers on Lua would provide trait-based argument extraction:

impl Lua {
    /// Get argument at 1-based index, converting via FromLua.
    /// Raises an argument error if the value is missing or wrong type.
    pub fn check_arg<V: FromLua>(&self, index: u32) -> LuaResult<V> { ... }

    /// Push a return value onto the stack, converting via IntoLua.
    pub fn push<V: IntoLua>(&mut self, value: V) -> LuaResult<()> { ... }
}

This would enable:

lua.register_function("greet", |lua| {
    let name: String = lua.check_arg(1)?;
    lua.push(format!("Hello, {name}!"))?;
    Ok(1)
});

Note: this also depends on closure-based function creation to accept a closure that captures &mut Lua instead of &mut LuaState.

Priority

These enhancements improve the embedding experience but are not required for Lua 5.1.1 compatibility. They should be implemented after the PUC-Rio test suite passes (Phase 9e).

Suggested implementation order:

  1. Container conversions (Vec<T>, HashMap, Option<T>) – standalone, no dependencies
  2. Tuple IntoLuaMulti / FromLuaMulti – standalone, enables later items
  3. Trait-based table access (Table::get<K,V>, Table::set<K,V>)
  4. Closure-based function creation (create_function)
  5. Trait-based call/resume (Lua::call<A,R>, Thread::resume<A,R>)
  6. Native function helpers (check_arg, push)
  7. UserData trait (depends on 2, 4, 5)

Lua Feature Matrix (5.1 – 5.5)

Cross-version feature reference for the Lua programming language. Each feature shows the version in which it was introduced, and when it was deprecated or removed.

rilua targets Lua 5.1.1 (the version embedded in the World of Warcraft game client). Features from later versions are documented for reference.

Legend

Version columns:

  • ✓ – Available (standard, documented in reference manual)
  • D – Deprecated (per reference manual or available only via compatibility flags)
  • (empty) – Not present (not yet introduced, or removed)

rilua column:

  • ✓ – Fully implemented
  • ~ – Partially implemented (known limitations)
  • ✗ – Not implemented

1. Types and Values

Feature5.15.25.35.45.5riluaNotes
nil
booleanOnly false and nil are falsy
number (float only)All numbers are f64 in 5.1–5.2
number (integer subtype)64-bit integers + 64-bit floats
stringImmutable, 8-bit clean byte sequences
functionFirst-class values
tableAssociative arrays
userdata (full)Arbitrary host data with metatables
userdata (light)Raw pointer, no metatable
threadCoroutine execution context
Reference semanticsTables, functions, threads, userdata
Automatic string-number coercionDDRestricted in 5.4+ (moved to string metamethods)

2. Lexical Elements

Feature5.15.25.35.45.5riluaNotes
21 reserved keywordsand break do else elseif end false for function if in local nil not or repeat return then true until while
22 reserved keywords5.2+ adds goto to the 5.1 set
23 reserved keywords5.5 adds global; effectively 22 when LUA_COMPAT_GLOBAL is on (default)
global keywordConditionally reserved; unreserved by default via LUA_COMPAT_GLOBAL
Short strings ("..." / '...')
Long strings ([[...]])With levels: [=[...]=]
Escape: \a \b \f \n \r \t \v \\ \" \'
Escape: \ddd (decimal byte)
Escape: \xHH (hex byte)5.2 feature
Escape: \z (skip whitespace)5.2 feature
Escape: \u{XXXX} (Unicode)UTF-8 encoding of codepoint
Decimal numeric literals42, 3.14, 1e10
Hexadecimal integer literals0xff
Hexadecimal float literals0x1.Bp10
Line comments (--)
Block comments (--[[...]])
Shebang line (#!)First line only
Empty statement (;)5.1 allows ; as separator only, not as statement

3. Variables and Scope

Feature5.15.25.35.45.5riluaNotes
Global variablesStored in environment table
Local variablesLexically scoped
Upvalues (closures)Inner functions capture outer locals
Function environments (setfenv/getfenv)Replaced by _ENV in 5.2
_ENV variableFree names translated to _ENV.var
_G global table
local x <const>Compile-time constant local
local x <close>Calls __close on scope exit
global declarationsglobal x, global *, global x <const>
Named vararg tablesNamed access to varargs

4. Statements and Control Flow

Feature5.15.25.35.45.5riluaNotes
do ... end blocksExplicit scope
Assignment (single and multiple)x, y = y, x
if ... elseif ... else ... end
while ... do ... endPre-test loop
repeat ... untilPost-test loop; body visible to condition
Numeric forfor i = start, limit, step do
Generic forfor k, v in iterator do
Generic for closing value4th variable: to-be-closed
Read-only for loop variablesControl variable in generic for is read-only
breakMust be last statement in 5.1
break anywhere in block5.1 requires do break end workaround
goto and ::label::Cannot jump into or across local scope
returnWith optional expression list
Function calls as statements

5. Expressions and Operators

5.1 Arithmetic

Feature5.15.25.35.45.5riluaNotes
+ - * / % ^ (unary -)
Floor division //Rounds quotient toward negative infinity

5.2 Bitwise

Feature5.15.25.35.45.5riluaNotes
& (AND)Language-level operator (not bit32 library)
| (OR)
~ (XOR / unary NOT)Binary XOR and unary NOT
<< >> (shifts)

5.3 Relational

Feature5.15.25.35.45.5riluaNotes
== ~= < > <= >=

5.4 Logical

Feature5.15.25.35.45.5riluaNotes
and or notShort-circuit evaluation

5.5 Other

Feature5.15.25.35.45.5riluaNotes
.. (concatenation)
# (length)
Table constructors {}Array, record, mixed
Parenthesized expressionsTruncate multi-return to single value

6. Functions

Feature5.15.25.35.45.5riluaNotes
Named definitionfunction f() end
Local definitionlocal function f() end
Anonymous (lambda)function() end
Method definition (:)Implicit self parameter
Dotted namesfunction a.b.c() end
No-parenthesis callSingle string or table arg
Method call (:)obj:method(args)
Multiple return values
Varargs (...)
Proper tail calls~Known bug with return-from-C
Closures (upvalues)
C/Rust functionsNative host functions
Light C functionsNo allocation overhead

7. Metatables and Metamethods

Feature5.15.25.35.45.5riluaNotes
getmetatable / setmetatable
__index (read)Table or function
__newindex (write)Table or function
__callCall non-function values
__add __sub __mul __div __mod __pow __unmArithmetic metamethods
__idivFloor division metamethod
__band __bor __bxor __bnot __shl __shrBitwise metamethods
__eq5.1-5.2: same type and same metamethod required; 5.3+: checks each operand’s metatable independently
__lt __le
__concat
__len5.2+ honors __len for tables
__tostring
__metatableProtect metatable from access
__mode (weak tables)"k", "v", or "kv"
__gc (userdata finalizer)
__gc (table finalizer)Extended to tables in 5.2
__pairsAdded in 5.2; present in source and manual index through 5.5
__ipairsDAdded in 5.2; deprecated in 5.3 (LUA_COMPAT_IPAIRS), removed in 5.4
__closeFor to-be-closed variables

8. Garbage Collection

Feature5.15.25.35.45.5riluaNotes
Incremental mark-sweepTri-color with write barriers
Generational mode (experimental)Removed in 5.3 as experimental
Generational mode (stable)Re-introduced in 5.4
Incremental major collectionsMajor GC done incrementally in 5.5
collectgarbage("collect")
collectgarbage("stop"/"restart")
collectgarbage("count")5.2 returns 2 values (kbytes, remainder); 5.1 and 5.3+ return single value
collectgarbage("step")
collectgarbage("setpause"/"setstepmul")Replaced by collectgarbage("param") in 5.5
collectgarbage("param")Unified parameter access: ("param", name [, value])
collectgarbage("isrunning")
collectgarbage("incremental")Switch to incremental mode; 5.4+ accepts parameters
collectgarbage("generational")Switch to generational mode; 5.4+ accepts parameters
Weak tables (__mode)
Ephemeron tablesWeak keys with value dependency
Emergency GCRuns GC on allocation failure
Userdata finalizersVia __gc metamethod
Table finalizersExtended __gc to tables
gcinfo()DDeprecated in 5.1; removed in 5.2

9. Coroutines

Feature5.15.25.35.45.5riluaNotes
coroutine.create
coroutine.resume
coroutine.yield
coroutine.wrapReturns iterator function
coroutine.status
coroutine.running5.2+ returns 2 values (thread, is-main)
coroutine.isyieldable
coroutine.closeClose coroutine and its to-be-closed vars
Yieldable pcall/xpcallCoroutines can yield across protected calls
Yieldable metamethods

10. Error Handling

Feature5.15.25.35.45.5riluaNotes
error(msg [, level])
pcall(f, ...)
xpcall(f, msgh)5.1 signature: 2 args only
xpcall(f, msgh, ...)5.2+ passes extra args to f
Error objects (any value)
warn(msg)Non-fatal warning system

11. Standard Library: Base Functions

Feature5.15.25.35.45.5riluaNotes
assert
collectgarbageOptions vary by version
dofile
error
getmetatable
ipairs5.3+ uses lua_geti (respects __index); 5.2 __ipairs metamethod separate
load5.2+ accepts string and mode/env args
loadfile5.2+ accepts mode/env args
next
pairs
pcall
print5.4+ calls __tostring directly
rawequal
rawget
rawset
rawlenLength without __len
select
setmetatable
tonumber
tostring
type
xpcallSignature changed in 5.2 (see sec. 10)
_G
_VERSION"Lua 5.x"
warnNon-fatal warnings
unpack (global)DDMoved to table.unpack in 5.2; 5.3 via LUA_COMPAT_UNPACK
loadstringDDUse load in 5.2+; 5.3 via LUA_COMPAT_LOADSTRING
getfenvRemoved in 5.2 (replaced by _ENV)
setfenvRemoved in 5.2 (replaced by _ENV)
moduleDDDeprecated in 5.2; 5.3 via LUA_COMPAT_MODULE
newproxyUndocumented in 5.1; removed in 5.2
gcinfoDDeprecated since 5.0; removed in 5.2

12. Standard Library: String

Feature5.15.25.35.45.5riluaNotes
string.byte
string.char
string.dump5.3+ adds strip parameter
string.find
string.format5.2+: %s calls __tostring via luaL_tolstring; 5.4+: %p specifier
string.gmatch5.4+ adds optional init parameter
string.gsub
string.len
string.lower
string.match
string.rep5.2+ adds optional sep parameter
string.reverse
string.sub
string.upper
string.packBinary data packing
string.unpackBinary data unpacking
string.packsizePacked size calculation
string.gfindDDeprecated alias for gmatch; removed in 5.2
String metatable (__index)Method syntax: s:upper()
Frontier pattern (%f)Implemented in 5.1 (undocumented); documented in 5.2
Pattern class %gPrintable characters (except space)
\0 in patternsNull bytes in patterns

13. Standard Library: Table

Feature5.15.25.35.45.5riluaNotes
table.concat
table.insert5.2+ stricter argument checking
table.remove5.2+ stricter argument checking
table.sort
table.unpackReplaces global unpack from 5.1
table.packReturns table with .n field
table.moveMove elements between positions/tables
table.createPre-allocate table with size hints
table.maxnDDLargest positive numeric key; 5.3 via LUA_COMPAT_MAXN
table.foreachDDeprecated since 5.0
table.foreachiDDeprecated since 5.0
table.getnDUse # operator instead
table.setnDNo replacement; removed in 5.2

14. Standard Library: Math

Feature5.15.25.35.45.5riluaNotes
math.abs
math.acos
math.asin
math.atan5.3+ accepts 2 args (replaces atan2)
math.ceil
math.cos
math.deg
math.exp
math.floor
math.fmod
math.log5.2+ adds optional base parameter
math.max
math.min
math.modf
math.rad
math.random5.4 switches to xoshiro256** RNG
math.randomseed
math.sin
math.sqrt
math.tan
math.hugePositive infinity
math.pi
math.maxintegerLargest integer value
math.minintegerSmallest integer value
math.tointegerConvert float to integer if fits
math.type"integer", "float", or false
math.ultUnsigned integer less-than comparison
math.atan2DDDUse math.atan(y, x) in 5.3+; 5.3+ via LUA_COMPAT_MATHLIB
math.coshDDD5.3+ via LUA_COMPAT_MATHLIB
math.sinhDDD5.3+ via LUA_COMPAT_MATHLIB
math.tanhDDD5.3+ via LUA_COMPAT_MATHLIB
math.powDDDUse x ^ y operator; 5.3+ via LUA_COMPAT_MATHLIB
math.frexpDDRestored as standard in 5.5; 5.3-5.4 via LUA_COMPAT_MATHLIB
math.ldexpDDRestored as standard in 5.5; 5.3-5.4 via LUA_COMPAT_MATHLIB
math.log10DDDDUse math.log(x, 10); 5.3+ via LUA_COMPAT_MATHLIB
math.modDAlias for fmod; deprecated since 5.0

15. Standard Library: I/O

Feature5.15.25.35.45.5riluaNotes
io.close
io.flush
io.input
io.lines5.2+ accepts read format options
io.open
io.output
io.popen
io.read5.2+ adds "*L" format (line with newline)
io.tmpfile
io.type
io.write
io.stdin / io.stdout / io.stderr
file:close5.2+: pipe close returns exit status
file:flush
file:lines5.2+ accepts format options
file:read
file:seek
file:setvbuf
file:write5.2+: returns file handle (for chaining)

16. Standard Library: OS

Feature5.15.25.35.45.5riluaNotes
os.clock
os.date
os.difftime
os.execute5.2+ returns true/nil, reason, code
os.exit5.2+ adds optional close parameter
os.getenv
os.remove
os.rename
os.setlocale
os.time
os.tmpname

17. Standard Library: Debug

Feature5.15.25.35.45.5riluaNotes
debug.debug~Stub
debug.gethookReturns hook function, mask string, count
debug.getinfo5.2+ adds nparams, isvararg, istailcall
debug.getlocal5.2+ accesses vararg info
debug.getmetatable
debug.getregistry
debug.getupvalue
debug.sethookLine, call, return, and count hooks
debug.setlocal
debug.setmetatable
debug.setupvalue
debug.traceback
debug.upvalueidUnique ID for upvalue
debug.upvaluejoinMake upvalues share
debug.getuservalue5.4+ supports multiple user values
debug.setuservalue5.4+ supports multiple user values
debug.setcstacklimitAdded in 5.4; removed in 5.5
debug.getfenvRemoved with environment model in 5.2
debug.setfenvRemoved with environment model in 5.2

18. Standard Library: Package

Feature5.15.25.35.45.5riluaNotes
require
package.config
package.cpath
package.loaded
package.loadlib~Loads rilua-native modules (not PUC-Rio C modules); requires dynmod feature
package.path
package.preload
package.searchpath
package.searchersReplaces package.loaders
package.loadersDDRenamed to package.searchers in 5.2; 5.3 via LUA_COMPAT_LOADERS
package.seeallDDDeprecated in 5.2; 5.3 via LUA_COMPAT_MODULE
moduleDDDeprecated in 5.2; 5.3 via LUA_COMPAT_MODULE

19. Standard Library: bit32

Feature5.15.25.35.45.5riluaNotes
bit32.arshiftDArithmetic right shift
bit32.bandDBitwise AND
bit32.bnotDBitwise NOT
bit32.borDBitwise OR
bit32.btestDTest bits
bit32.bxorDBitwise XOR
bit32.extractDExtract bits
bit32.replaceDReplace bits
bit32.lrotateDLeft rotate
bit32.lshiftDLeft shift
bit32.rrotateDRight rotate
bit32.rshiftDRight shift

20. Standard Library: UTF-8

Feature5.15.25.35.45.5riluaNotes
utf8.charCreate UTF-8 string from codepoints
utf8.charpatternPattern matching one UTF-8 character
utf8.codesIterator over codepoints
utf8.codepointGet codepoints from string
utf8.lenCount UTF-8 characters
utf8.offset5.5: also returns final position

21. rilua: Interpreter CLI

Reproduces the PUC-Rio Lua 5.1.1 standalone interpreter (lua.c).

Feature5.15.25.35.45.5riluaNotes
rilua [options] [script [args]]
Option -e statExecute string
Option -iInteractive mode after script
Option -l nameRequire library
Option -vVersion info
Option --Stop option handling
Option -Execute stdin
Option -EIgnore environment variables
Option -WTurn on warnings
LUA_INIT env varExecute string or @filename
arg tablearg[0] is script name
REPL / interactive mode_PROMPT / _PROMPT2 globals
REPL =expr shorthandCalculator mode added in 5.3 alongside; =expr removed in 5.5

22. rilua: Bytecode Compiler CLI

Reproduces the PUC-Rio luac bytecode compiler/lister.

Feature5.15.25.35.45.5riluaNotes
riluac [options] [files]
Option -lList bytecode
Option -l -lList with constants and locals
Option -pParse only (syntax check)
Option -o fileWrite binary output
Option -sStrip debug info
Option -vVersion info

23. rilua: Rust Embedding API

These features are rilua-specific (not part of the Lua language standard) but correspond to the C API provided by PUC-Rio Lua.

FeatureriluaNotes
Lua struct (state ownership)Main VM state
IntoLua / FromLua traitsType-safe value conversion
Table handleRead/write table fields
Function handleCall Lua functions from Rust
Thread handleCoroutine manipulation
AnyUserData handleTyped Rust data in Lua
StdLib bitflagsSelective library loading
LuaResult<T> error handlingResult-based (no longjmp)
Lua::load() / Lua::load_bytes()Load and compile chunks
Lua::gc_collect() etc.GC control from Rust
Registry tableGlobal storage for host

Version Evolution Summary

Lua 5.1 (2006) – rilua target

The baseline. All numbers are f64. Function environments via setfenv/getfenv. Module system via module() and package.loaders. Pattern matching (not regex). Incremental GC. Coroutines. 21 keywords.

Lua 5.2 (2011)

Replaced function environments with _ENV lexical scoping. Added goto/labels, bit32 library, table.pack/table.unpack, rawlen, yieldable pcall/xpcall, ephemeron tables, table finalizers (__gc), hex string escapes, hexadecimal floats, package.searchpath. Documented frontier patterns (%f, already implemented in 5.1). Removed setfenv/getfenv, newproxy. Deprecated module, loadstring, global unpack, table.maxn, math.log10.

Lua 5.3 (2015)

Added 64-bit integer subtype alongside floats. Introduced native bitwise operators (&, |, ~, <<, >>), floor division (//), utf8 library, string.pack/unpack/packsize, table.move, math.tointeger/math.type/math.maxinteger/math.mininteger, coroutine.isyieldable, math.ult, \u{XXXX} string escapes, string.dump strip parameter. Changed ipairs to use lua_geti (respects __index metamethods). Deprecated bit32 library, math.atan2, math.cosh/sinh/tanh, math.pow, math.frexp/ldexp, math.log10. Deprecated __ipairs metamethod. Removed experimental generational GC.

Lua 5.4 (2020)

Added to-be-closed variables (<close>), const locals (<const>), generic for closing value (4th variable), stable generational GC, warn() function and -W CLI flag, coroutine.close, __close metamethod, string.gmatch init parameter, string.format %p, xoshiro256** RNG, debug.setcstacklimit, multiple user values for userdata. Relaxed __eq to check each operand’s metatable independently (no longer requires same metamethod). Removed bit32 library and __ipairs metamethod. Deprecated math functions (atan2, cosh, sinh, tanh, pow, frexp, ldexp, log10) remain behind LUA_COMPAT_MATHLIB (under LUA_COMPAT_5_3). Restricted implicit string-to-number coercion (moved to string metatable metamethods).

Lua 5.5 (2025)

Added global keyword for explicit global declarations, named vararg tables, read-only for-loop variables, table.create, incremental major GC collections, compact arrays (60% less memory for large arrays), decimal float printing, collectgarbage("param") unified parameter API. Enhanced utf8.offset to return final position. Restored math.frexp and math.ldexp as standard functions (no longer behind compat flag). Removed debug.setcstacklimit, =expr REPL shorthand, collectgarbage("setpause"/"setstepmul").


Corrections from Previous Versions

This matrix corrects version attributions found during verification against the Lua reference manuals and PUC-Rio source code.

Round 1 corrections (version attribution errors)

  • Bitwise operators (&, |, ~, <<, >>) are language-level features added in 5.3, not 5.2. The bit32 library (a function library, not operators) was added in 5.2.
  • rawlen was added in 5.2, not 5.3.
  • table.move was added in 5.3, not 5.2.
  • coroutine.isyieldable was added in 5.3, not 5.2.
  • \xHH and \z string escapes were added in 5.2, not 5.1.
  • Empty statement (; as a statement) was added in 5.2. In 5.1, ; is only a statement separator.
  • math.log10 was deprecated in 5.2 (with math.log(x, 10) as replacement), not 5.3.

Round 2 corrections (manual/source verification)

  • Frontier pattern %f: Was already implemented in 5.1.1 source code (lstrlib.c lines 383–393) but undocumented. 5.2 officially documented it. The Lua 5.2 readme lists “frontier patterns” but this refers to documentation, not new code. rilua implements %f.
  • __pairs metamethod: Was not removed in 5.3. It remained functional through 5.3 (previously corrected from “removed in 5.3” to “removed in 5.4”; see Round 3 for further correction).
  • __ipairs metamethod: Was deprecated (not removed) in 5.3. The 5.3 manual says “its __ipairs metamethod has been deprecated.” It was removed in 5.4.
  • =expr REPL shorthand: Was not removed in 5.3. Calculator mode (addreturn) was added in 5.3, but =expr coexisted through 5.4 with a “for compatibility with 5.2” comment. =expr was finally removed in 5.5.

Round 3 corrections (source code and rilua verification)

  • \xHH and \z escape sequences: rilua column was ✓ but should be . The rilua lexer (scan_short_string) only handles 5.1 escapes (\a \b \f \n \r \t \v \\ \" \' \<newline> \ddd). Previous note “rilua includes as extension” was incorrect.
  • table.unpack: rilua column was ✓ but should be . rilua only registers the global unpack (5.1 standard). table.unpack is a 5.2+ feature not present in the table library.
  • __pairs metamethod: Was never removed from PUC-Rio source or manual index. lbaselib.c:luaB_pairs unconditionally checks __pairs in 5.2, 5.3, 5.4, and 5.5. Corrected from “removed in 5.4” to ✓ in all versions 5.2 through 5.5.
  • string.format %s and __tostring: Started in 5.2 (when luaL_tolstring was introduced), not 5.3. The 5.1 lstrlib.c uses luaL_checklstring (no metamethod); 5.2+ uses luaL_tolstring.
  • Reserved keyword count: 5.1 has 21 keywords. 5.2+ has 22 (adds goto). Split into separate rows to avoid implying 5.2+ also has 21.
  • Math compat functions (atan2, cosh, sinh, tanh, pow, frexp, ldexp, log10): Available in 5.4 behind LUA_COMPAT_MATHLIB (via LUA_COMPAT_5_3). Marked D instead of empty.
  • math.frexp and math.ldexp: Restored as standard functions in 5.5 (always available, not behind any compat flag). Marked ✓ in 5.5.
  • global keyword: Conditionally reserved in 5.5. Unreserved by default when LUA_COMPAT_GLOBAL is defined (which it is by default).
  • __eq note: Removed “5.2+ requires same type and same metamethod” – this restriction applies to all versions including 5.1. The 5.1 source (get_compTM) already requires both operands to share the same metamethod function.
  • collectgarbage("count") note: Clarified that 5.2 is the outlier returning 2 values (kbytes + remainder). Both 5.1 and 5.3+ return a single combined value.
  • table.insert/table.remove stricter checking: Changed “5.3+” to “5.2+”. Position bounds validation via luaL_argcheck was introduced in 5.2, not 5.3.

Round 4 Corrections

  • debug.setcstacklimit: Changed 5.5 from ✓ to empty. This function was added in 5.4 but removed in 5.5 (not present in 5.5 dblib[]).
  • package.loaders: Changed 5.3 from empty to D. Available in 5.3 via LUA_COMPAT_LOADERS (under LUA_COMPAT_5_1).
  • module and package.seeall: Changed 5.3 from empty to D. Available in 5.3 via LUA_COMPAT_MODULE (under LUA_COMPAT_5_1).
  • Version Evolution Summary (5.4): Changed “Removed __pairs/__ipairs metamethods” to “Removed __ipairs metamethod”. The __pairs metamethod was never removed (present unconditionally in lbaselib.c through 5.5).
  • Version Evolution Summary (5.4): Changed “Removed … deprecated math functions” to “Moved … behind LUA_COMPAT_MATHLIB”. The table data correctly shows D (not empty) for these functions in 5.4, so “Removed” was contradictory.

Round 5 Corrections

  • xpcall(f, msgh, ...): Changed rilua column from ✓ to ✗. The rilua implementation matches the 5.1 signature (2 args only); extra arguments are not forwarded to f (state.top = func_pos + 1 in base.rs).
  • Legend: Refined D definition from “available via compatibility flags, not recommended” to “per reference manual or available only via compatibility flags”. In 5.1, functions like gcinfo, table.foreach, table.foreachi, table.getn are deprecated per the reference manual but unconditionally registered (no compat flag). The original wording only described the later-version compat-flag pattern.

Round 6 Corrections

  • collectgarbage("incremental"): Added ✓ for 5.2. Present in opts[] as LUA_GCINC (value 11) for switching from generational to incremental mode. Removed in 5.3 (with experimental generational GC), re-added in 5.4.
  • collectgarbage("generational"): Added ✓ for 5.2. Present in opts[] as LUA_GCGEN (value 10). Same lifecycle as "incremental".
  • module (section 11): Changed 5.3 from empty to D for consistency with section 18 (which was already corrected in Round 4). Available in 5.3 via LUA_COMPAT_MODULE under LUA_COMPAT_5_1.

Round 7 Corrections (Lua 5.3 focused)

  • unpack (global): Changed 5.3 from empty to D. Available in 5.3 via LUA_COMPAT_UNPACK (under LUA_COMPAT_5_1).
  • loadstring: Changed 5.3 from empty to D. Available in 5.3 via LUA_COMPAT_LOADSTRING (under LUA_COMPAT_5_1).
  • table.maxn: Changed 5.3 from empty to D. Available in 5.3 via LUA_COMPAT_MAXN (under LUA_COMPAT_5_2).
  • Math compat function notes: Changed “5.4-5.5 via LUA_COMPAT_MATHLIB” to “5.3+ via LUA_COMPAT_MATHLIB” for math.atan2, math.cosh, math.sinh, math.tanh, math.pow, math.log10. The flag exists in 5.3 under LUA_COMPAT_5_2, same mechanism as 5.4’s LUA_COMPAT_5_3.
  • math.frexp/math.ldexp notes: Changed “5.4 via LUA_COMPAT_MATHLIB” to “5.3-5.4 via LUA_COMPAT_MATHLIB”. Same compat mechanism applies in 5.3.
  • ipairs note: Changed “5.3+ respects metamethods” to clarify it uses lua_geti (which respects __index), distinct from the 5.2 __ipairs metamethod.
  • Added math.ult row: New in 5.3 (unsigned integer less-than). Present unconditionally in mathlib[] in 5.3+.
  • 5.3 version summary: Added math.ult, string.dump strip parameter, ipairs metamethod behavioral change, and math.log10 deprecation.

Round 8 Corrections (Lua 5.4 focused)

  • __eq note: Changed “Same type and same metamethod required (all versions)” to distinguish 5.1-5.2 behavior (requires same metamethod via get_compTM) from 5.3+ behavior (checks each operand’s metatable independently via luaT_gettmbyobj).
  • 5.4 version summary: Added math.log10 to the list of deprecated math functions behind LUA_COMPAT_MATHLIB (was omitted while the other 7 functions were listed). Added debug.setcstacklimit, multiple user values for userdata, generic for closing value, -W CLI flag, and __eq relaxation. Reworded “Moved … behind LUA_COMPAT_MATHLIB” to “remain behind LUA_COMPAT_MATHLIB (under LUA_COMPAT_5_3)” since these were already behind the flag in 5.3.

Round 9 Corrections (Lua 5.5 focused)

  • collectgarbage("setpause"/"setstepmul"): Changed 5.5 from ✓ to empty. These options were removed in 5.5, replaced by the unified collectgarbage("param", name [, value]) API (LUA_GCPARAM). The opts[] array in 5.5’s lbaselib.c no longer contains "setpause" or "setstepmul".
  • Added collectgarbage("param") row: New in 5.5 only. Provides unified get/set for GC parameters: "pause", "stepmul", "stepsize", "minormul", "majorminor", "minormajor".
  • Reserved keyword count for 5.5: Changed “22 reserved keywords” from ✓ to empty for 5.5. Added new “23 reserved keywords” row for 5.5. The 5.5 llex.h enum lists 23 reserved words (including global); LUA_COMPAT_GLOBAL (on by default) unreserves global, making the effective count 22, but the manual lists 23.
  • 5.5 version summary: Added restorations (math.frexp/math.ldexp), removals (debug.setcstacklimit, =expr REPL shorthand, collectgarbage("setpause"/"setstepmul")), and the new collectgarbage("param") API.

Standard Library

Decision

Modular implementation, one file per library, matching PUC-Rio Lua 5.1.1 standard library behavior.

Overview

Lua 5.1.1 ships with 9 standard libraries (base, coroutine, string, table, math, io, os, debug, package). The coroutine library is registered as part of luaopen_base() but occupies its own namespace. Each library is a collection of functions registered in a table (or as globals for the base library). Libraries can be loaded selectively — an embedded Lua environment may omit io and os for sandboxing.

Libraries

Base Library (stdlib/base.rs)

Global functions not in any table.

FunctionStatusNotes
assertRequiredError with optional message
collectgarbageRequired7 options
dofileRequiredLoad and execute file
errorRequiredThrow error object at level
getfenvRequiredGet function environment
getmetatableRequiredGet metatable (respects __metatable)
ipairsRequiredInteger key iterator
loadRequiredLoad chunk from function
loadfileRequiredLoad chunk from file
loadstringRequiredLoad chunk from string
gcinfoRequiredDeprecated GC info (returns KB used)
nextRequiredTable traversal
pairsRequiredGeneric table iterator
pcallRequiredProtected call
printRequiredPrint to stdout (uses tostring)
rawequalRequiredEquality without metamethods
rawgetRequiredTable access without metamethods
rawsetRequiredTable assignment without metamethods
_GRequiredGlobal table reference
selectRequiredselect(n, ...) or select('#', ...)
setfenvRequiredSet function environment
setmetatableRequiredSet metatable (respects __metatable)
tonumberRequiredConvert to number (with base)
tostringRequiredConvert to string (uses __tostring)
typeRequiredType name as string
unpackRequiredTable to multiple values
xpcallRequiredProtected call with error handler
_VERSIONRequired"Lua 5.1"
newproxyOptionalUndocumented, creates proxy userdata

Base Library Behavioral Notes

assert(v [, message]) — if v is nil or false, calls error(message). Default message is "assertion failed!". Returns all arguments on success.

error(message [, level]) — level 0 means no position prefix. Level 1 (default) prefixes with the current function’s location. Level 2 uses the caller’s location, etc. If message is not a string, no position prefix is added.

getfenv(f)f can be a function or a number (stack level). Level 0 returns the thread environment. Level 1 (default) returns the current function’s environment. getfenv(0) differs from getfenv() (the latter defaults to level 1).

getmetatable(object) — if the metatable has a __metatable field, returns that field’s value instead of the actual metatable. This protects metatables from user inspection.

ipairs(t) — returns an iterator function, the table, and 0. The iterator returns index, value pairs starting at 1 until t[index] is nil. Uses raw access (no metamethods).

pcall(f, ...) — calls f(...) in protected mode. Returns true, results... on success or false, error on failure. The error object can be any type, not just strings.

select(index, ...) — if index is "#", returns the count of remaining arguments. If index is negative, counts from the end. Error: "index out of range" if the resulting position is < 1.

setfenv(f, table) — level 0 changes the thread environment (returns nothing). Cannot change environments of C functions (error: "'setfenv' cannot change environment of given object").

setmetatable(table, metatable) — first arg must be a table (not userdata). If the existing metatable has a __metatable field, error: "cannot change a protected metatable".

tonumber(e [, base]) — base 10 uses lua_isnumber (handles strings, hex 0xff, whitespace). Other bases (2-36) use unsigned integer conversion only. Returns nil on failure.

tostring(e) — checks __tostring metamethod first. Without metamethod: numbers use "%.14g" format, booleans produce "true"/"false", nil produces "nil", other types produce "typename: pointer".

unpack(list [, i [, j]])i defaults to 1, j defaults to #list. Returns list[i] through list[j] using raw access. Error: "table too big to unpack" if the range exceeds stack space.

xpcall(f, err) — calls f() with zero arguments (extra args are discarded). The error handler receives the original error object and its return value becomes the error returned by xpcall.

Coroutine Library (stdlib/base.rs)

FunctionNotes
coroutine.createCreate coroutine from function
coroutine.resumeResume suspended coroutine
coroutine.runningReturn running coroutine (returns nothing if main thread)
coroutine.statusReturn status string (running/suspended/normal/dead)
coroutine.wrapCreate coroutine as iterator function
coroutine.yieldSuspend execution, return values to resume

String Library (stdlib/string.rs)

Registered as the string table and as the string metatable’s __index.

FunctionNotes
string.byteCharacter codes
string.charCharacters from codes
string.dumpDump function bytecode
string.findPattern matching search
string.formatFormatted string output
string.gmatchGlobal pattern match iterator
string.gsubGlobal pattern substitution
string.lenString length
string.lowerLowercase conversion
string.matchPattern match extraction
string.repString repetition
string.reverseString reversal
string.subSubstring extraction
string.upperUppercase conversion
string.gfindDeprecated alias for gmatch (works by default; raises error only if LUA_COMPAT_GFIND is undefined)

Lua 5.1 patterns are NOT regular expressions. They support character classes (%a, %d, %w, etc.), anchors (^, $), quantifiers (*, +, -, ?), captures, and backreferences (%1 through %9 to match a previous capture). They do not support alternation.

String Library Behavioral Notes

string.byte(s [, i [, j]])i defaults to 1, j defaults to i. Negative positions count from end. Returns one integer per byte (0-255). Stack check: "string slice too long".

string.char(...) — each argument must be 0-255 (error: "invalid value"). No arguments returns empty string.

string.dump(function) — function must be a Lua function, not a C function (error: "unable to dump given function").

string.find(s, pattern [, init [, plain]])init defaults to 1 (negative counts from end). Plain mode does literal substring search. Returns start, end, captures... on match, nil on failure. Positions are 1-based.

string.format(formatstring, ...) — specifiers: c d i o u x X e E f g G q s and %%. Flags: - + (space) # 0. Width/precision max 2 digits each. %q produces a Lua-readable quoted string (escapes ", \, newlines, \r, \0). %s with no precision and string >= 100 chars pushes directly (no truncation).

string.gmatch(s, pattern) — returns an iterator. Each call returns the next match’s captures. Empty matches advance by 1 character to prevent infinite loops.

string.gsub(s, pattern, repl [, n]) — replacement can be string (%0=match, %1-%9=captures, %%=literal %), function (called with captures; falsy return keeps original), or table (first capture as key; falsy result keeps original). Returns the result string and substitution count.

string.sub(s, i [, j])j defaults to -1 (end of string). Negative positions count from end. Returns empty string if start > end.

Pattern Language Specification

Character classes (from match_class in lstrlib.c):

ClassMatchesNegation
%aletters (isalpha)%A
%ccontrol characters (iscntrl)%C
%ddigits (isdigit)%D
%llowercase letters (islower)%L
%ppunctuation (ispunct)%P
%swhitespace (isspace)%S
%uuppercase letters (isupper)%U
%walphanumeric (isalnum)%W
%xhex digits (isxdigit)%X
%zthe null byte (\0)%Z
%.literal . (any % + non-letter = literal)

Bracket classes: [abc] matches any of a, b, c. [^abc] negated. [a-z] ranges. % classes work inside brackets.

Single character matchers: . matches any character. %x matches a class. [...] matches a bracket class. Anything else matches literally.

Quantifiers:

QuantifierMeaningStrategy
*0 or moreGreedy (max first, backtrack)
+1 or moreGreedy
-0 or moreLazy (min first, extend)
?0 or 1Greedy

Anchors: ^ at pattern start anchors to beginning. $ at pattern end anchors to end. Elsewhere they are literal.

Captures: (...) captures matched text. () captures the position (1-based integer) instead of text. Maximum 32 captures (LUA_MAXCAPTURES). Backreferences: %1 through %9 match the same text as the corresponding capture.

Special patterns: %bxy matches balanced delimiters (e.g., %b() matches balanced parentheses). %f[set] is a frontier pattern — matches a position where the previous character does not match [set] and the current character does. At string start, the “previous character” is \0.

Error conditions: "malformed pattern (ends with '%%')", "malformed pattern (missing ']')", "invalid capture index", "unfinished capture", "invalid pattern capture", "too many captures", "missing '[' after '%%f' in pattern".

Table Library (stdlib/table.rs)

FunctionNotes
table.concatConcatenate array elements
table.insertInsert element at position
table.maxnMaximum positive numeric key
table.removeRemove element at position
table.sortIn-place sort
table.foreachDeprecated: iterate table (use pairs)
table.foreachiDeprecated: iterate array (use ipairs)
table.getnDeprecated: table length (use # operator)
table.setnDeprecated: raises error in 5.1.1

Table Library Behavioral Notes

table.concat(list [, sep [, i [, j]]]) — sep defaults to "", i defaults to 1, j defaults to #list. Each element must be a string or number (error: "table contains non-strings"). Uses raw access. Returns empty string if i > j.

table.insert(list, [pos,] value) — 2 args appends at end. 3 args inserts at pos, shifting elements up. Error: "wrong number of arguments to 'insert'" for other counts.

table.maxn(list) — scans ALL keys (both parts) via next(). Returns the largest positive numeric key (including non-integer keys like 1.5). Returns 0 if no positive numeric keys exist.

table.remove(list [, pos]) — pos defaults to #list (remove from end). Shifts elements down, sets last element to nil. Returns the removed element, or nothing if the table was empty.

table.sort(list [, comp]) — Quicksort with median-of-three pivot. Tail recursion on the larger partition. Default comparison uses < (invokes __lt metamethods). Error: "invalid order function for sorting" if the comparison is inconsistent (e.g., NaN values break strict weak ordering). Not stable.

table.setn(table, n) — error: "'setn' is obsolete".

Math Library (stdlib/math.rs)

FunctionNotes
math.absAbsolute value
math.acosArc cosine
math.asinArc sine
math.atanArc tangent
math.atan2Two-argument arc tangent
math.ceilCeiling
math.cosCosine
math.coshHyperbolic cosine
math.degRadians to degrees
math.expExponential
math.floorFloor
math.fmodFloat modulo
math.frexpDecompose float
math.hugeInfinity constant
math.ldexpScale by power of 2
math.logNatural logarithm
math.log10Base-10 logarithm
math.maxMaximum
math.minMinimum
math.modDeprecated alias for fmod (enabled by default via LUA_COMPAT_MOD)
math.modfInteger and fractional parts
math.piPi constant
math.powPower
math.radDegrees to radians
math.randomRandom number
math.randomseedSet random seed
math.sinSine
math.sinhHyperbolic sine
math.sqrtSquare root
math.tanTangent
math.tanhHyperbolic tangent

Math Library Behavioral Notes

All single-argument functions use f64 methods from Rust’s standard library. Each takes one number argument and returns one number.

math.random([m [, n]]) — 0 args: float in [0, 1). 1 arg: integer in [1, m]. 2 args: integer in [m, n]. Error: "interval is empty" if the range is invalid. Uses C rand() equivalent (deterministic for a given seed).

math.randomseed(x) — seeds the random generator. Argument must be convertible to integer.

math.min(...) / math.max(...) — requires at least 1 argument. NaN asymmetry: if NaN is the first argument, it is returned. If NaN appears later, it is skipped (since NaN < x and NaN > x are both false).

math.frexp(x) — returns 2 values: mantissa m and integer exponent e where x = m * 2^e and 0.5 <= |m| < 1.

math.modf(x) — returns 2 values: integer part and fractional part.

math.log(x) — natural logarithm only (no base parameter in 5.1). Base-10 is math.log10.

Lua % vs math.fmod: the Lua % operator uses a - floor(a/b)*b (result has same sign as b), while math.fmod uses C fmod (result has same sign as a). Example: -1 % 5 is 4 in Lua but math.fmod(-1, 5) is -1.

I/O Library (stdlib/io.rs)

FunctionNotes
io.closeClose file
io.flushFlush output
io.inputSet/get default input
io.linesLine iterator
io.openOpen file
io.outputSet/get default output
io.popenOpen process (platform-dependent)
io.readRead from default input
io.tmpfileCreate temporary file
io.typeCheck file handle type
io.writeWrite to default output
File methods:close, :flush, :lines, :read, :seek, :setvbuf, :write
io.stdinStandard input file handle
io.stdoutStandard output file handle
io.stderrStandard error file handle

OS Library (stdlib/os.rs)

FunctionNotes
os.clockCPU time
os.dateDate formatting
os.difftimeTime difference
os.executeRun shell command
os.exitExit process
os.getenvEnvironment variable
os.removeDelete file
os.renameRename file
os.setlocaleSet locale
os.timeCurrent time
os.tmpnameTemporary file name

Debug Library (stdlib/debug.rs)

FunctionNotes
debug.debugInteractive debug prompt
debug.getfenvGet environment
debug.gethookGet hook function
debug.getinfoFunction information
debug.getlocalLocal variable value
debug.getmetatableRaw metatable
debug.getregistryRegistry table
debug.getupvalueUpvalue value
debug.setfenvSet environment
debug.sethookSet hook function
debug.setlocalSet local variable
debug.setmetatableSet metatable
debug.setupvalueSet upvalue
debug.tracebackStack traceback

Package Library (stdlib/package.rs)

Function/FieldNotes
requireModule loader (registered as global)
moduleCreate module (registered as global)
package.configDirectory/path separator configuration string
package.cpathC module search path
package.loadedCache of loaded modules
package.loadersOrdered list of module searchers
package.loadlibLoad native module (see Native Module Loading)
package.pathLua module search path
package.preloadPre-registered module loaders
package.seeallSet module environment to globals

Native Module Loading

PUC-Rio Lua’s package.loadlib loads C modules via lua_CFunction (int (*)(lua_State *)). rilua cannot load PUC-Rio C modules because:

  • Rust has no stable ABI. The internal types (LuaState, Val) change layout between compiler versions.
  • rilua’s function signature (fn(&mut LuaState) -> LuaResult<u32>) differs from PUC-Rio’s extern "C" fn(*mut lua_State) -> c_int.
  • Building a C API compatibility shim (lua.h-compatible) would require reimplementing the entire PUC-Rio stack API (~120 functions) with extern "C" wrappers, plus maintaining ABI stability guarantees that Rust does not provide.

Instead, rilua defines its own native module ABI. Modules are Rust cdylib crates compiled against the same rilua version and rustc version as the host. This is gated behind the dynmod Cargo feature (default off). Without the feature, package.loadlib returns (nil, msg, "absent").

When dynmod is enabled:

  • package.loadlib(path, funcname) loads a shared library, validates a RILUA_MODULE_INFO descriptor (magic bytes, version, struct sizes), and looks up the named entry point.
  • The C module loaders (package.loaders[3] and [4]) search package.cpath for modules named rilua_open_<modname>.
  • Library handles are stored as userdata with a __gc metamethod that calls dlclose/FreeLibrary on collection.

See src/dynmod.rs for the ABI contract and examples/native_module/ for a working example.

Loading

Libraries are loaded via Lua::new() (all standard libraries) or selectively via Lua::new_with(StdLib).

Mirrored at examples/selective_stdlib.rs.

use rilua::{Lua, StdLib};

fn main() -> rilua::LuaResult<()> {
    let mut lua = Lua::new_with(
        StdLib::BASE | StdLib::STRING | StdLib::TABLE | StdLib::MATH,
    )?;
    // io, os, debug, package omitted (sandboxed)
    lua.exec(r#"print(string.upper("ok"))"#)?;
    Ok(())
}

Error Message Formats

All luaL_error messages are prefixed by source location ("source:line: " or empty string).

Argument errors: "bad argument #N to 'funcname' (message)". For methods, narg is decremented by 1 (implicit self). If narg becomes 0: "calling 'name' on bad self (message)".

Type errors: "bad argument #N to 'funcname' (expected expected, got actual)".

Key format constants:

ConstantValue
LUA_MAXCAPTURES32
LUA_NUMBER_FMT"%.14g"

Implementation Priority

  1. Base library (with coroutine) — required for any Lua program
  2. String library — heavily used, pattern matching is complex
  3. Table library — common operations
  4. Math library — straightforward wrappers around f64 methods
  5. I/O library — file operations
  6. OS library — system operations
  7. Package library — module system
  8. Debug library — introspection, lowest priority

WebAssembly Support

rilua compiles to wasm32-unknown-unknown for running Lua 5.1.1 code in the browser or other WebAssembly runtimes.

How It Works

When targeting wasm32, src/platform.rs swaps every extern "C" function for a pure-Rust stub. Only the platform layer changes; the VM, compiler, and core standard libraries are unmodified.

platform.rs
  |
  +-- #[cfg(not(target_arch = "wasm32"))]  -> extern "C" { ... }
  |
  +-- #[cfg(target_arch = "wasm32")]       -> wasm_stubs module

Stubs and Replacements

C FunctionWASM Replacement
strtodf64::from_str() (ASCII decimal only)
localeconvStatic LConv with decimal_point = '.'
strcollByte-wise comparison (no locale)
setlocaleNo-op (returns "C")
strftimeReturns 0 (no formatting)
localtime_r / gmtime_rReturns false (no time conversion)
mktimeReturns -1
clockReturns -1
time(NULL)Returns 0
isalpha, tolower, etc.ASCII-only Rust equivalents
FILE* operationsReturn null/error values
popen / pcloseReturn null/-1
signalNo-op (SIGINT handling disabled)

Locale Differences

On native platforms, rilua uses strtod and localeconv for locale-aware number parsing (matching PUC-Rio behavior where 3,14 parses as a number in locales using comma as decimal separator).

On WASM, number parsing is ASCII-only with . as the decimal point. This matches the "C" locale and is correct for all standard Lua programs.

Standard Library Availability

Libraries that need filesystem or process access are still loadable on WASM but their functions return errors. Libraries that are pure computation work without restrictions.

LibraryWASM StatusNotes
baseFullAll 29 functions work
stringFullAll 14 functions work, pattern matching included
tableFullAll 9 functions work
mathFullAll 28 functions work
coroutineFullAll 6 functions work
debugFullAll 14 functions work (no filesystem dependency)
ioErrorsFile operations return nil, "not supported"
osPartialos.clock, os.date, os.time return defaults; os.execute, os.remove, os.rename, os.tmpname error
packageLimitedrequire works for preloaded modules; file-based loading fails

Sandboxed Loading

For WASM builds, load only the libraries that work:

use rilua::{Lua, StdLib};

let libs = StdLib::BASE | StdLib::STRING | StdLib::TABLE
         | StdLib::MATH | StdLib::COROUTINE;
let mut lua = Lua::new_with(libs)?;

Building for WASM

Prerequisites

  • Rust 1.92+ with the wasm32-unknown-unknown target
  • wasm-pack (for browser builds)
rustup target add wasm32-unknown-unknown

As a Library Dependency

Add rilua to your WASM crate’s Cargo.toml:

[dependencies]
rilua = "0.1"
wasm-bindgen = "0.2"

[lib]
crate-type = ["cdylib"]

Example lib.rs:

use wasm_bindgen::prelude::*;
use rilua::{Lua, StdLib};

#[wasm_bindgen]
pub fn eval_lua(code: &str) -> String {
    let libs = StdLib::BASE | StdLib::STRING | StdLib::TABLE
             | StdLib::MATH | StdLib::COROUTINE;

    let mut lua = match Lua::new_with(libs) {
        Ok(l) => l,
        Err(e) => return format!("init error: {e}"),
    };

    match lua.exec(code) {
        Ok(()) => String::new(),
        Err(e) => format!("{e}"),
    }
}

Build with wasm-pack:

wasm-pack build --target web

Browser Demo

A working browser demo is in examples/wasm-demo/. It provides a textarea for Lua code and renders output in the page. See examples/wasm-demo/README.md for build and serve instructions.

The demo replaces print with a version that writes to a thread-local String buffer (stdout does not exist in WASM), then returns the captured output after execution.

Feature Interactions

FeatureWASM Behavior
dynmodDisabled (no shared library loading on WASM)
sendWorks (GcRef indices are just u32 values)
SIGINTNo-op (no signal handling on WASM)

Limitations

See the Standard Library Availability table and Locale Differences section above for specifics. In summary: no filesystem, no process control, no locale, no clock, ASCII-only number parsing.

print writes to stdout, which does not exist in WASM. Override it to capture output (as the browser demo does).

Testing Strategy

Decision

Spec-driven, multi-layer testing. Unit tests for internals, oracle comparison for behavioral equivalence, integration tests for language semantics, PUC-Rio official test suite as the compatibility target.

Current: 1325 tests (609 unit, 431 integration, 277 oracle, 5 proptest, 3 lua51). With dynmod feature: 1333 tests (611 unit, 6 dynmod, 431 integration, 277 oracle, 5 proptest, 3 lua51). All 5 layers are active.

Test Layers

Layer 1: Unit Tests

In-module #[cfg(test)] blocks testing internal components in isolation. Every implementation chunk includes unit tests.

Lexer tests (src/compiler/lexer.rs):

  • Token type recognition for all keywords and symbols
  • Number literal parsing (decimal, hex, float, exponent)
  • String literal parsing (escapes, long brackets)
  • Comment handling (single-line, long comments)
  • Error cases (unterminated strings, invalid escapes)
  • Source position tracking

Parser tests (src/compiler/parser.rs):

  • Expression parsing with operator precedence
  • Statement parsing for each statement type
  • Block and scope handling
  • Error recovery and error messages
  • AST structure verification

Compiler tests (src/compiler/codegen.rs):

  • Instruction emission for each AST node type
  • Register allocation
  • Constant pool management
  • Upvalue resolution
  • Jump backpatching

VM tests (src/vm/):

  • Instruction dispatch correctness
  • Stack manipulation
  • GC arena allocation and collection
  • Table operations (get, set, resize)
  • String interning
  • Closure creation and upvalue management

Layer 2: Oracle Comparison Tests

Oracle comparison tests run the same Lua code in both rilua and PUC-Rio Lua 5.1.1, comparing output to verify behavioral equivalence. This catches divergences that unit tests and integration tests might miss.

Reference Binaries

  • lua (interpreter): ./lua-5.1.1/src/lua
  • luac (compiler/lister): ./lua-5.1.1/src/luac

Both are built from the official PUC-Rio Lua 5.1.1 tarball. See AGENTS.md for download, verification, and build instructions.

The lua binary path is configured via the LUA_REFERENCE_BIN environment variable (defaults to ./lua-5.1.1/src/lua). Tests that require the reference binary skip gracefully if it is not available.

Oracle Test Framework

Test helpers in tests/helpers/:

// tests/helpers/oracle.rs

/// Run code in PUC-Rio Lua 5.1.1 and return (stdout, stderr, exit_code).
fn run_reference(code: &str) -> (String, String, i32);

/// Run code in rilua and return (stdout, stderr).
fn run_rilua(code: &str) -> (String, String);

/// Assert rilua produces the expected stdout for the given code.
fn assert_output(code: &str, expected: &str);

/// Run in both interpreters, assert stdout matches.
fn assert_matches_reference(code: &str);

Usage in tests:

#[test]
fn arithmetic_matches_reference() {
    assert_matches_reference("print(1 + 2)");
    assert_matches_reference("print(2 ^ 10)");
    assert_matches_reference("print(10 % 3)");
    assert_matches_reference("print(-7 % 3)");  // floor modulo
}

Bytecode Comparison

After the compiler is implemented, bytecode comparison tests compile Lua snippets with both rilua and luac -l, then compare instruction output. This verifies the compiler produces correct bytecode before the VM exists.

/// Compile code with rilua and return a formatted instruction listing.
fn compile_rilua(code: &str) -> String;

/// Compile code with PUC-Rio luac -l and return the listing.
fn compile_reference(code: &str) -> String;

/// Assert both compilers produce equivalent bytecode.
fn assert_bytecode_matches(code: &str);

Bytecode comparison checks instruction opcodes and operands. It does not compare constant pool ordering or debug info formatting, since these may differ between implementations without affecting semantics.

When Each Test Category Activates

CategoryActivates afterMechanism
Unit testsPhase 0 (skeleton)cargo test --lib
Bytecode comparisonPhase 2 (compiler)Compare rilua compiler output with luac -l
Oracle comparisonPhase 3 + printCompare rilua output with lua -e
Integration .lua testsPhase 3 + assertcargo test --test integration
PUC-Rio test suitePhase 5a (base lib)cargo test --test lua51

Layer 3: Integration Tests

Lua scripts in tests/ that exercise language features through the full pipeline. Each test uses assert() to validate behavior.

Organization mirrors the Lua 5.1 Reference Manual. Language tests cover Chapter 2 (“The Language”), standard library tests cover Chapter 5 (“Standard Libraries”).

Language tests (Chapter 2):

FileSectionDescription
lexical.lua2.1Lexical conventions (keywords, names, strings, numbers, comments)
types.lua2.2Values and types, coercion (2.2.1)
variables.lua2.3Global, local, and table field variables
statements.lua2.4Chunks, blocks, assignment, control structures, for loops, local declarations
expressions.lua2.5Arithmetic, relational, logical operators, concatenation, length, precedence, table constructors, function calls, function definitions
visibility.lua2.6Lexical scoping, upvalues, closures
errors.lua2.7error(), pcall, xpcall, error objects, stack traces
metatables.lua2.8Metamethods for arithmetic, comparison, indexing, call, concatenation, length
environments.lua2.9Function environments, setfenv, getfenv
gc.lua2.10Garbage collection, finalizers (2.10.1), weak tables (2.10.2)
coroutines.lua2.11create, resume, yield, wrap, status, error propagation

Standard library tests (Chapter 5):

FileSectionDescription
stdlib-base.lua5.1Base library (assert, type, tonumber, tostring, select, unpack, etc.)
stdlib-package.lua5.3Package/module library (require, module, loaders, etc.)
stdlib-string.lua5.4String library (find, format, gmatch, gsub, etc.)
stdlib-table.lua5.5Table library (concat, insert, remove, sort, maxn)
stdlib-math.lua5.6Math library (abs, floor, ceil, random, sin, cos, etc.)
stdlib-io.lua5.7I/O library (open, read, write, lines, etc.)
stdlib-os.lua5.8OS library (clock, date, time, execute, etc.)
stdlib-debug.lua5.9Debug library (getinfo, getlocal, sethook, traceback, etc.)

Test infrastructure files: tests/helpers/mod.rs (shared utilities), tests/helpers/oracle.rs (PUC-Rio comparison), tests/integration.rs (runner), tests/lua51.rs (PUC-Rio suite runner).

Layer 4: PUC-Rio Official Test Suite

The PUC-Rio Lua 5.1.1 test suite (./lua-5.1-tests/) is the compatibility target. These are verbatim test files from the official Lua test tarball. See AGENTS.md for download instructions.

Official Running Modes

Per lua.org/tests/, the test suite has three running modes:

  1. Portable mode: lua -e"_U=true" all.lua – skips system-dependent tests and memory-intensive operations.
  2. Full mode: lua all.lua – tests every corner of the language. Requires compiled C libraries in libs/ subdirectory.
  3. Internal mode: Recompile Lua with ltests.c/ltests.h to enable the T (testC) library for internal VM tests.

What all.lua Does

The all.lua runner is not a simple file list. It modifies the runtime environment before running each test:

  • Sets GC parameters: collectgarbage("setstepmul", 180) and setpause(190).
  • Redefines dofile to round-trip every test file through string.dump + loadstring, implicitly testing binary chunk serialization and deserialization.
  • Wraps big.lua in coroutine.wrap (the file yields values).
  • calls.lua expects a deep variable set by main.lua.
  • Sets a debug.sethook call/return hook during cleanup (line 118).

How rilua Runs These Tests

rilua does not run all.lua directly. Instead, each test file is executed individually using two approaches:

Important: Tests must be run from the lua-5.1-tests/ directory. Several tests depend on relative paths: attrib.lua creates files in libs/, math.lua and verybig.lua require checktable.lua via LUA_PATH, and file tests reference paths relative to the test directory. Running from the project root will cause false failures.

Individual file execution (primary):

# Run from the test directory (required)
cd lua-5.1-tests
mkdir -p libs

# Run a single test
RILUA_TEST_LIB=1 LUA_PATH="?;./?.lua" ../target/release/rilua <test>.lua

# Run all tests
for f in *.lua; do
  [ "$f" = "all.lua" ] && continue
  echo -n "$(basename $f .lua): "
  timeout 30 env RILUA_TEST_LIB=1 LUA_PATH="?;./?.lua" \
    ../target/release/rilua "$f" >/dev/null 2>&1 && echo "PASS" || echo "FAIL"
done

Comparison script (scripts/compare.sh):

# Compare all test files between PUC-Rio and rilua
scripts/compare.sh ./lua-5.1.1/src/lua ./target/release/rilua

The comparison script runs each .lua file individually (except all.lua) with a 10-second timeout, reporting PASS/FAIL/TIMEOUT for both interpreters. This differs from all.lua in that:

  • No string.dump/loadstring round-trip (tests run directly).
  • No GC parameter tuning.
  • No inter-test state (big.lua runs standalone, not in a coroutine; calls.lua runs without deep from main.lua).
  • Each test gets a fresh interpreter state.

rilua also passes all.lua directly (see What all.lua Does for its additional requirements).

T Module

rilua implements PUC-Rio’s internal test library (T global), the Rust equivalent of ltests.c. Activate it with the RILUA_TEST_LIB=1 environment variable. 25 functions are registered:

FunctionDescription
T.querytabReturns (array size, hash size) for a table
T.hashReturns hash-part index for a key in a table
T.int2fbConverts integer to float-byte encoding
T.log2Returns floor(log2(x))
T.listcodeReturns list of opcodes for a function
T.setyhookSets yield-on-hook for a coroutine thread
T.resumeResumes a coroutine (no arguments)
T.d2sConverts f64 to 8-byte native-endian string
T.s2dConverts 8-byte native-endian string to f64
T.testCC API mini-interpreter (28 commands)
T.newuserdataCreate userdata with given byte size
T.udatavalReturn unique integer ID for userdata
T.pushuserdataFind/create userdata by its ID
T.refStore object in registry, return integer key
T.unrefRemove registry entry
T.getrefGet value from registry by key
T.upvalueGet/set upvalue n of closure f
T.checkmemoryNo-op stub (GC consistency check)
T.gsubString substitution
T.doonnewstackRun code in a new coroutine
T.newstateCreate independent Lua state
T.closestateClose a state created by newstate
T.doremoteExecute code string in remote state
T.loadlibLoad standard libraries into remote state
T.totalmemGet/set memory limit (OOM simulation)

Four tests (api.lua, checktable.lua, closure.lua, code.lua) use T extensively. When T is nil, guarded sections (if T then ... end) are skipped. All four pass with RILUA_TEST_LIB=1.

Test Files

Test FileArea
all.luaTest runner (chains all tests with dump/undump)
api.luaC API interactions (requires T.testC)
attrib.luarequire/package system, assignments, operators
big.luaString overflow, large line counts, table constructs
calls.luaFunction calls and returns
checktable.luaTable invariant checker (utility functions only)
closure.luaClosures, upvalues, and coroutines
code.luaCode generation, optimizations (uses T.listcode)
constructs.luaSyntax, operator priority, language constructs
db.luaDebug library
errors.luaError handling
events.luaMetatables and metamethods
files.luaI/O library
gc.luaGarbage collection
literals.luaScanner/lexer and literal parsing
locals.luaLocal variables
main.luaStandalone interpreter (lua.c) options
math.luaMath library
nextvar.luaTables, next(), size operator, for loops
pm.luaPattern matching
sort.luatable.sort
strings.luaString library
vararg.luaVararg functions
verybig.luaVery large programs

Current Status

All 23 files pass: api, attrib, big, calls, checktable, closure, code, constructs, db, errors, events, files, gc, literals, locals, main, math, nextvar, pm, sort, strings, vararg, verybig.

The all.lua runner also passes (see What all.lua Does for its additional requirements beyond individual file execution).

Compatibility flags: The PUC-Rio test suite was written with default compat options enabled (e.g., LUA_COMPAT_VARARG enables the arg table in vararg functions). WoW’s Lua disables some of these. Tests that depend on compat options may need conditional handling.

Layer 5: Behavioral Equivalence Tests

Tests that specifically verify behavioral equivalence with PUC-Rio Lua 5.1.1. These test edge cases where implementations commonly diverge:

  • Numeric formatting (tostring(0.1), string.format("%g", 0.1))
  • Error message wording (programs may match on error strings)
  • GC behavior (collectgarbage return values)
  • Weak table clearing timing
  • Finalizer execution order
  • String-to-number coercion edge cases
  • Integer overflow behavior (all numbers are f64)
  • Modulo with negative operands (floor division)
  • Concatenation type coercion

These tests use the oracle comparison framework to run each case in both rilua and PUC-Rio, comparing exact output. They are the last line of defense before the PUC-Rio test suite.

Test Workflow

Test-Driven Development

New features are implemented test-first where possible:

  1. Write a Lua test script that exercises the feature.
  2. Run the test against PUC-Rio Lua 5.1.1 to verify expected behavior.
  3. Implement the feature in rilua.
  4. Run the test and fix until it passes.
  5. Run the oracle comparison to verify matching output.
  6. Run the full test suite to check for regressions.

This ensures every feature is validated against the reference implementation.

Quality Gate

Every commit must pass:

cargo fmt -- --check && \
cargo clippy --all-targets && \
cargo test && \
cargo doc --no-deps

This ensures:

  1. Consistent formatting
  2. No lint warnings
  3. All tests pass (unit, integration, oracle, PUC-Rio suite)
  4. Documentation builds without errors

Coverage Tracking

Test coverage is measured by:

  1. Feature coverage – which Lua 5.1.1 features are implemented and tested (tracked in CHANGELOG.md).
  2. PUC-Rio test suite progress – 23 of 23 official test files passing (tracked in CI).
  3. Oracle comparison count – number of Lua snippets verified against PUC-Rio output.
  4. Code coveragecargo-tarpaulin or llvm-cov for line coverage metrics (informational, not a gate).

Performance

Performance characteristics, benchmarks against PUC-Rio Lua 5.1.1, and optimization history.

Goal: PUC-Rio Parity

The target is matching PUC-Rio Lua 5.1.1 (compiled with -O2) on the official test suite. PUC-Rio Lua is written in C and represents the performance floor for a Lua 5.1 implementation.

Benchmark Environment

PropertyValue
CPUAMD Ryzen 7 8840U w/ Radeon 780M Graphics
OSFedora Linux 43 (kernel 6.18)
RustEdition 2024, --release profile
PUC-RioLua 5.1.1, compiled with gcc -O2 -DLUA_USE_LINUX
Runs10 per test, median reported
Date2026-02-23

Per-Test Results (ms, median of 10 runs)

Tests from the PUC-Rio test suite run individually. main.lua and big.lua are excluded: main.lua tests CLI features via os.execute (environment-dependent), and big.lua requires a coroutine wrapper set by all.lua.

TestPUC-RioriluaRatio
gc.lua70851.21x
db.lua16301.88x
calls.lua791.29x
strings.lua331.00x
literals.lua331.00x
attrib.lua441.00x
locals.lua461.50x
constructs.lua2525832.31x
code.lua221.00x
nextvar.lua13282.15x
pm.lua11111.00x
api.lua331.00x
events.lua331.00x
vararg.lua221.00x
closure.lua581.60x
errors.lua1351481.10x
math.lua561.20x
sort.lua55981.78x
verybig.lua1152171.89x
files.lua12131.08x
Sum72012621.75x

Interpretation

rilua is 1.75x slower than PUC-Rio Lua overall. Most tests are within 1.0-1.5x. Four tests account for the majority of the gap:

  • constructs.lua (2.31x, +331ms): heavy control-flow constructs, deeply nested loops and conditionals. This test stresses the VM dispatch loop.
  • nextvar.lua (2.15x, +15ms): table iteration (next, pairs), global table manipulation. Stresses table hash traversal.
  • verybig.lua (1.89x, +102ms): large function compilation and execution with many locals and upvalues.
  • db.lua (1.88x, +14ms): debug library operations, getinfo, getlocal, hook management.
  • sort.lua (1.78x, +43ms): table.sort with comparison callbacks. Function call overhead per comparison.

Tests at or near parity (1.0-1.1x): strings.lua, literals.lua, attrib.lua, code.lua, pm.lua, api.lua, events.lua, vararg.lua, files.lua.

Combined Runner

bench-all.lua runs all 20 standalone tests sequentially in a single interpreter session (like all.lua but without main.lua/big.lua and without the dump/undump dofile override).

RunnerPUC-RioriluaRatio
bench-all.lua79215291.93x

The combined runner is slower than the sum of individual tests (1.93x vs 1.75x). Running all tests in a single interpreter session accumulates more live objects across test boundaries, increasing GC work per cycle.

Reproducing

Build both interpreters and run the benchmark script:

# Build PUC-Rio Lua 5.1.1
cd lua-5.1.1 && make linux && cd ..

# Build rilua
cargo build --release

# Run benchmarks (default: 10 runs per test)
./scripts/benchmark-tests.sh [runs]

Optimization History

Starting from ~15.4s on the full suite, four optimization phases reduced runtime to ~2.6s (83% total reduction).

Phase 1: Lexer and Parser (~7% improvement)

  • Keyword lookup: match dispatch replacing binary search on sorted array
  • Parser advance: mem::replace replacing Token::clone
  • Lexer: fast-path byte-slice scanning for common characters
  • GC traverse: zero-allocation indexed access for tables and closures

Phase 2: Constant Pool (~68% reduction)

  • Hash-based constant pool deduplication replacing O(n) linear scan
  • Mirrors PUC-Rio’s addk approach using luaH_set on fs->h
  • ConstantKey enum: Num(u64) / Bool(bool) / Str(Vec<u8>)
  • 15.4s -> 4.9s

Phase 3: GC and VM Inlining (~12% reduction)

  • #[inline] on hot GC arena and collector methods
  • sweep_partial: direct assignment replacing mem::replace on dead path
  • GCSWEEPMAX: 40 -> 80 to amortize dispatch overhead
  • traverse_thread: indexed access replacing Vec clone allocation
  • CallInfo.is_lua cache: eliminates arena lookups in traceback
  • 4.9s -> 4.3s

Phase 4: SoA Sweep Layout (~46% reduction)

  • Parallel Vec<u8> color array (Structure-of-Arrays layout)
  • Sweep reads 1 byte per slot instead of loading full Entry<T> (~72 bytes for tables)
  • Iterator-based sweep: eliminates per-access bounds checks
  • 4.9s -> 2.6s (10-run median)

Profiling

Requirements

  • Linux with perf installed (linux-tools-common or equivalent)
  • cargo-flamegraph: cargo install flamegraph

Generating Flamegraphs

Build with debug symbols in release mode (already configured in Cargo.toml via [profile.release] debug = true if needed):

# Profile a specific test file
cargo flamegraph -- -e "dofile('lua-5.1-tests/constructs.lua')"

# Profile the full test suite
cd lua-5.1-tests
RILUA_TEST_LIB=1 cargo flamegraph -- all.lua

Flamegraph SVGs are interactive. Open them in a browser to click-zoom into specific call stacks and search for function names.

Generated flamegraphs go in flamegraphs/ (gitignored).

Using perf Directly

cargo build --release
perf record -g --call-graph dwarf target/release/rilua lua-5.1-tests/constructs.lua
perf report

Benchmarks

Criterion Microbenchmarks

benches/interpreter.rs contains criterion benchmarks covering:

  • State creation: empty, base libs, full stdlib
  • Compilation: minimal, loops, functions, tables
  • VM execution: arithmetic loops, fibonacci, string concat, tables, closures, metatable dispatch
  • GC: full collect, allocation churn, incremental stepping
  • String interning: unique strings, dedup hits
  • Table operations: integer keys, string keys, mixed Lua ops
  • End-to-end: compile+run, coroutine cycles

Run with:

cargo bench

Results go to target/criterion/. Use --save-baseline and --baseline flags to compare across changes.

PUC-Rio Full Suite Benchmark

The primary wall-clock benchmark:

cargo build --release
./scripts/bench-puc-rio.sh [binary] [runs]

Arguments:

  • binary: path to rilua binary (default: target/release/rilua)
  • runs: number of runs (default: 5)

Output: min, median, and max times. Prints median to stdout.

Regression Gate

scripts/perf-gate.sh compares the current build against the stored baseline with a configurable threshold (default 5%).

./scripts/perf-gate.sh [baseline_ms] [threshold_pct]

If no arguments are given, reads .perf-baseline and uses 5%.

The script:

  1. Builds release
  2. Runs bench-puc-rio.sh with 5 iterations
  3. Compares median against baseline + baseline * threshold / 100
  4. Exits 0 (pass) or 1 (regression detected)

After a confirmed improvement, update the baseline:

./scripts/bench-puc-rio.sh > .perf-baseline

Optimization Priorities

Based on the per-test benchmarks, these areas offer the largest potential gains, ordered by impact:

1. VM Dispatch (constructs.lua: 2.31x, +331ms)

constructs.lua is the heaviest test and the largest absolute gap. It exercises the main execute() loop with deeply nested control flow.

  • Instruction dispatch: the match-based dispatch in execute() is the hot path. Layout optimization, opcode reordering to improve branch prediction, and reducing per-instruction overhead would have the highest impact.
  • FORPREP/FORLOOP specialization: integer-only fast path for numeric for loops when bounds are integers.

2. Table Operations (nextvar.lua: 2.15x, sort.lua: 1.78x)

  • Hash traversal: next() and pairs() iteration speed. nextvar.lua hammers these.
  • Comparison callback overhead: sort.lua calls a Lua comparison function per element pair. Reducing function call setup/teardown cost would help.

3. Compilation (verybig.lua: 1.89x, +102ms)

  • AST allocation: heap-allocated AST nodes dropped after compilation. A pool or arena built from Vec-based storage could reduce allocation pressure.
  • Constant folding: limited constant folding during compilation could reduce VM work for arithmetic-heavy code.

4. GC Under Sustained Load (bench-all.lua: 1.93x)

The combined runner is 10% slower relative to PUC-Rio than the sum of individual tests (1.93x vs 1.75x). This indicates GC overhead grows disproportionately with accumulated state. Incremental GC tuning and sweep efficiency under high object counts are the targets here.

5. Lower-Priority Opportunities

  • String concatenation: batching consecutive CONCAT operations to reduce intermediate allocations.
  • Generational GC: nursery for young objects, tenured for survivors. Would reduce per-cycle work for allocation-heavy programs.
  • Hash function: alternative hash functions could reduce collision rates for specific workloads.

Comparison with Other Implementations

How rilua compares to PUC-Rio Lua, mlua, Luau, and lua-rs in architecture, performance, API design, and trade-offs.

Overview

riluaPUC-Rio LuamluaLuaulua-rs
LanguageRustCRust (FFI to C)C++Rust
Lua Version5.1.15.1 - 5.55.1 - 5.5, Luau5.1 derivative5.5
TypeNative interpreterReference implBinding layerNative interpreterNative interpreter
LicenseMITMITMITMITMIT
Dependencies00 (libc)7+ runtime0 (C++ stdlib)5 runtime
WASMwasm32-unknown-unknownNowasm32-unknown-emscriptenNowasm32-unknown-unknown
Unsafe Code0 in core VM/GCN/A (C)Extensive (FFI)N/A (C++)~400 blocks
Nightly RustNoN/ANoN/AYes

Architecture

rilua

Pipeline: Lexer -> Parser -> AST -> Compiler -> Bytecode -> VM.

38 register-based opcodes matching PUC-Rio’s instruction set. Values are a Rust enum (Val) with GcRef indices into typed arenas. Arena-based incremental mark-sweep GC with generational indices. No unsafe code in the VM, compiler, or GC. Errors propagate via Result<T, LuaError> – no setjmp/longjmp, no panics in library code.

Protos (compiled function bodies) are reference-counted (Rc<Proto>) rather than GC-managed, reducing GC traversal cost for immutable data.

PUC-Rio Lua

The reference implementation and baseline for all Lua interpreters. Same 38-opcode register-based VM. Values are tagged unions (TValue) with a mark-sweep GC using linked lists of GCObject pointers. Error handling uses setjmp/longjmp. Written in ANSI C for portability.

PUC-Rio’s GC walks linked lists of heap-allocated objects. Each object carries color bits inline. The design prioritizes simplicity and portability over cache locality.

mlua

mlua is not a Lua implementation. It is a Rust binding layer over PUC-Rio’s C implementation (or LuaJIT, or Luau). The actual Lua execution happens in C/C++ code linked via FFI.

Every C API call that can trigger a Lua error is wrapped in lua_pcall to prevent longjmp from unwinding Rust stack frames. The library states it contains “a huge amount of unsafe code” to bridge the C/Rust boundary. Users do not write unsafe in normal usage, but the FFI boundary is inherently fragile.

When using the vendored feature, mlua compiles PUC-Rio’s C source (or Luau’s C++ source) from lua-src-rs during build. This requires a C/C++ compiler in the toolchain.

Luau

Roblox’s derivative of Lua 5.1 with a rewritten interpreter, optional native code generation (x64/ARM64), and a gradual type system. The bytecode format differs from PUC-Rio. The interpreter uses inline caching for table field access.

Luau removes several Lua 5.1 features for sandboxing: string.dump, loadstring with bytecode, __gc metamethods, setfenv/getfenv. It adds buffer (typed byte arrays), table.freeze, table.clone, string interpolation, compound assignments, and continue.

lua-rs

CppCXY/lua-rs is an almost one-to-one port of PUC-Rio’s C Lua 5.5 source to Rust. The module structure mirrors the C codebase directly: lparser.c becomes compiler/parser, lgc.c becomes gc/, ltable.c becomes lua_value/lua_table/, and so on.

Pipeline: Lexer -> Parser -> Code Generator -> Bytecode -> VM (same as C Lua, no intermediate AST).

86 opcodes matching Lua 5.5’s instruction set with multiple formats (iABC, iABx, iAsBx, iAx, isJ). Tri-color incremental and generational GC ported from lgc.c, including object aging (NEW/SURVIVAL/OLD), ephemeron cleanup, and __gc finalizers. String interning with short/long string distinction (threshold: 40 bytes).

The project uses unsafe extensively (~400 blocks) for GC object pointer manipulation, upvalue handling, and table internals – the same operations that are pointer-based in C. No external C FFI; all unsafe is internal Rust. Requires nightly Rust for unchecked_shifts and other unstable features.

Runtime dependencies: ahash, rand, chrono, itoa, smol_str. Optional: serde/serde_json for JSON support.

Performance

Relative Speed

Implementationvs PUC-Rio 5.1 (interpreted)Notes
PUC-Rio Lua 5.11.0x (baseline)C, -O2
rilua~1.7x slowerPure Rust, --release, 0 unsafe
lua-rs~1.2x slowerRust, --release, nightly, ~400 unsafe
mlua~1.0x (wraps PUC-Rio)FFI overhead at boundaries only
Luau (interpreted)Faster than PUC-RioOptimized dispatch, inline caching
Luau (native codegen)1.5-2.5x faster than Luau interpretedx64/ARM64 only

Measured on AMD Ryzen 7 8840U, release builds, median of 10 runs. Sum of 20 individual PUC-Rio test files: PUC-Rio 696ms, rilua 1167ms, mlua 211ms (8 tests that passed).

rilua’s overhead comes from four areas: VM dispatch loop (constructs.lua 2.26x), table hash traversal (nextvar.lua 2.0x), compilation cost (verybig.lua 1.87x), and function call overhead in sorting callbacks (sort.lua 1.76x). Tests that do not stress these paths run at or near parity.

lua-rs’s closer-to-C performance reflects its one-to-one port approach: the same pointer-based GC object layout, string interning with flat bucket arrays, and unchecked bit shifts in the instruction decoder. The unsafe blocks map to operations that are pointer arithmetic in the original C. This preserves C Lua’s performance characteristics at the cost of Rust’s safety guarantees in those paths.

mlua adds minimal overhead because execution happens in PUC-Rio’s C VM. The FFI crossing cost exists at every Rust<->Lua boundary call but is small relative to VM execution time. Micro-benchmarks confirm mlua matches PUC-Rio within noise (1.0-1.2x).

Luau’s interpreter is faster than PUC-Rio through instruction-level optimizations, inline caching, and tuned memory allocation. With native code generation enabled, compute-heavy code sees an additional 1.5-2.5x speedup.

Where rilua is Competitive

For workloads dominated by string operations, pattern matching, file I/O, and simple control flow, rilua matches PUC-Rio. The overhead is concentrated in tight loops with many VM dispatch cycles, table iteration, and deep function call chains.

For embedding scenarios where Lua execution is a small fraction of total runtime (e.g., configuration evaluation, scripting hooks), the 1.7x factor is unlikely to be noticeable.

Micro-Benchmarks (All Implementations)

Minimal Lua scripts that run on incomplete implementations too:

TestPUC-Riorilualua-rsmlualua-in-rust
fib.lua (recursive fib(35))670ms1751ms (2.61x)900ms (1.34x)652ms (1.01x)3784ms (5.85x)
loop.lua (1M iterations)6ms15ms (2.50x)7ms (1.17x)7ms (1.17x)22ms (3.67x)
tables.lua (100K insert+read)5ms9ms (1.80x)6ms (1.20x)5ms (1.00x)23ms (4.60x)
closures.lua (500K calls)14ms45ms (3.21x)19ms (1.36x)13ms (1.00x)
nested_loops.lua (1Mx1K)11ms28ms (2.55x)9ms (0.82x)11ms (1.22x)34ms (3.78x)

Ratios are vs PUC-Rio. lua-in-rust could not run closures.lua (runtime crash on upvalue access). lua-rs requires nightly Rust. Benchmark script and runner: scripts/benchmark-implementations.sh.

Rust API

rilua

Trait-based API inspired by mlua’s design:

use rilua::vm::state::LuaState;
use rilua::{Lua, LuaApiMut, LuaResult};

let mut lua = Lua::new()?;
lua.set_global("x", 42)?;
let val: i32 = lua.global("x")?;

// Register a Rust function -- function pointer, not a closure.
fn add(state: &mut LuaState) -> LuaResult<u32> {
    let a = state.check_number(1)?;
    let b = state.check_number(2)?;
    state.push_number(a + b);
    Ok(1)
}
lua.register_function("add", add)?;

// UserData
lua.create_typed_userdata::<MyType>(value)?;

Key characteristics:

  • Handle types (Table, Function, Thread, AnyUserData) are Copy – they are u32 indices into GC arenas
  • IntoLua / FromLua traits for type conversion
  • RustFn is fn(&mut LuaState) -> LuaResult<u32> (push returns, return count)
  • GC control: gc_collect(), gc_stop(), gc_step(), etc.
  • No lifetime parameters on handles – arena indices remain valid until the Lua state is dropped or GC collects the object

mlua

More feature-rich API with closures, async, scoped borrows:

use mlua::prelude::*;

let lua = Lua::new();
lua.globals().set("x", 42)?;
let val: i32 = lua.globals().get("x")?;

// Closure-based function creation
let add = lua.create_function(|_, (a, b): (f64, f64)| {
    Ok(a + b)
})?;
lua.globals().set("add", add)?;

// UserData via derive or trait impl
impl UserData for MyType {
    fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
        methods.add_method("foo", |_, this, ()| Ok(this.value));
    }
}

Key characteristics:

  • Closures as Lua functions (captures state, not just fn pointers)
  • UserData trait with method/field registration
  • Scope for non-'static borrows in callbacks
  • Async function support (create_async_function)
  • Serde integration (serialize/deserialize Lua values)
  • RegistryKey for persistent references across calls
  • Module authoring (#[mlua::lua_module] proc macro)

lua-rs

Feature-rich API closer to mlua’s level, with closures, async, derive macros, and table builders:

use luars::{LuaVM, Stdlib, LuaValue, TableBuilder};
use luars::lua_vm::SafeOption;

let mut vm = LuaVM::new(SafeOption::default());
vm.open_stdlib(Stdlib::All)?;

// Execute and get results
let results = vm.execute("return 1 + 2")?;

// Register a Rust function
vm.register_function("add", |state| {
    let a = state.get_arg(1).unwrap();
    let b = state.get_arg(2).unwrap();
    // ...
    Ok(1)
})?;

// UserData via derive macro
#[derive(LuaUserData)]
struct Point { x: f64, y: f64 }
vm.register_type_of::<Point>("Point")?;

// Async support
vm.register_async("fetch", |args| async move {
    Ok(vec![AsyncReturnValue::string("result".into())])
})?;

Key characteristics:

  • LuaVM as the main state object
  • LuaValue enum for Lua values
  • TableBuilder for fluent table construction
  • register_type_of / register_enum for exposing Rust types
  • register_async with coroutine-based bridging
  • LuaError (1-byte enum) with optional LuaFullError (rich messages)
  • Optional Serde support for JSON conversion
  • Requires nightly Rust

API Comparison

Featureriluamlualua-rs
Function registrationfn pointersClosures (captures)Closures (captures)
UserDatacreate_typed_userdataUserData trait + deriveLuaUserData derive
AsyncNoYes (async feature)Yes (coroutine bridging)
SerdeNoYes (serde feature)Yes (serde feature)
Scoped borrowsNoYes (Scope)No
Module authoringNoYes (proc macro)No
Handle modelCopy indicesReference-countedValue-based
Error typeLuaError enummlua::Error enumLuaError / LuaFullError
Dependencies07+5
C compiler neededNoYes (vendored) or system LuaNo
Nightly RustNoNoYes

rilua’s API is intentionally smaller. It covers the embedding use case (create state, load code, call functions, exchange data) without the framework features mlua and lua-rs provide. The trade-off is fewer capabilities but zero external dependencies and no C toolchain requirement.

lua-rs sits between rilua and mlua in API richness: it offers closures, async, derive macros, and Serde – features rilua lacks – but without mlua’s scoped borrows or module authoring support. Unlike mlua, it requires no C compiler but does require nightly Rust.

Safety

Memory Safety

riluamlualua-rsLuau
Unsafe in coreNoneExtensive (FFI)~400 blocksN/A (C++)
User-facing unsafeNoneNone (normal usage)NoneN/A
Error modelResult<T>Result<T> (wraps longjmp)Result<T>longjmp (C++)
GC safetyGenerational indicesC GC + prevent-collection guardsGC object pointers (unsafe)C++ GC
Use-after-freeImpossible (index validation)Possible if guards misusedPossible (unsafe internals)Possible (C++)

rilua’s arena-based GC uses generational indices: each arena slot has a generation counter incremented on free. A GcRef stores both the slot index and the generation it was created with. Accessing a freed slot returns an error rather than corrupted data. This provides use-after-free protection without unsafe code.

lua-rs’s GC is a direct port of C Lua’s lgc.c. GC objects are heap-allocated and managed through raw pointers, mirroring the C implementation’s GCObject* linked lists. The ~400 unsafe blocks are concentrated in GC traversal, object dereferencing, string interning, and table internals. No external C FFI is involved – all unsafe is internal pointer manipulation for performance parity with C. The user-facing API does not expose unsafe.

mlua wraps every error-capable C API call in lua_pcall to catch longjmp. This prevents Rust stack unwinding but adds overhead and complexity. The library acknowledges the approach cannot guarantee 100% safety due to the fundamental tension between longjmp and Rust’s ownership model.

Sandboxing

Luau has the strongest sandboxing story: no bytecode loading, no __gc metamethods, restricted collectgarbage, per-script global isolation (safeenv), and a VM interrupt mechanism for terminating runaway scripts.

rilua and PUC-Rio Lua 5.1 expose string.dump, loadstring with bytecode, __gc metamethods, and setfenv/getfenv. These are faithful to the Lua 5.1 specification but provide less isolation than Luau.

Send/Sync (Thread Safety)

riluamlualua-rs
Default!Send, !Sync!Send, !Sync!Send, !Sync
With featureSend (feature = send)Send + Sync (feature = send)No Send support
MechanismGcRef is u32 (trivially Send)Reentrant mutex around VMN/A
OverheadNone (index-based handles)Lock acquisition on every accessN/A
ConstraintUserData: Send requiredUserData: Send requiredN/A

rilua’s send feature works because GcRef<T> values are plain u32 indices – they contain no pointers and are trivially Send. The Lua struct gets unsafe impl Send gated on the feature flag. There is no synchronization overhead.

mlua’s send feature wraps the Lua VM in a parking_lot reentrant mutex, making it Send + Sync. Every VM access acquires the lock. This is correct but adds per-operation overhead.

lua-rs does not provide a Send feature. Its GC uses raw pointers internally, which would require additional safety analysis to make Send-compatible.

Neither rilua nor mlua makes concurrent access safe without external synchronization. Both assume single-threaded access to the Lua state and use Send to allow moving the state between threads.

Feature Differences

What rilua Has That Others Do Not

  • Zero dependencies: no C compiler, no system libraries, no pkg-config, no nightly Rust
  • No unsafe in core: the VM, GC, compiler, and stdlib contain zero unsafe blocks
  • Behavioral equivalence with PUC-Rio 5.1: bytecode-compatible, same 38 opcodes, same GC states, same stdlib edge cases
  • Send support: feature-gated Send for multi-threaded embedding (no overhead, no mutex)

What lua-rs Has That rilua Does Not

  • Lua 5.5: latest language features (generational GC modes, bitwise ops, integers, goto, UTF-8 library)
  • Closer-to-C performance: ~1.2x PUC-Rio vs rilua’s ~1.7x
  • Closure-based function creation: captures arbitrary state
  • Async support: coroutine-based async Rust function bridging
  • Serde integration: Lua to/from JSON
  • UserData derive macro: #[derive(LuaUserData)] with #[lua_methods]
  • Published crate: available on crates.io

What mlua Has That rilua and lua-rs Do Not

  • Multiple Lua versions: 5.1 through 5.5, LuaJIT, Luau
  • Scoped borrows: non-'static references in callbacks
  • Module authoring: build .so/.dll loadable by Lua
  • PUC-Rio C performance: execution at native C speed
  • Send + Sync: mutex-based thread safety (with overhead)

What Luau Has That the Rest Lack

  • Gradual type system: optional type annotations with inference
  • Native code generation: AOT compilation for x64/ARM64
  • buffer type: typed byte array operations
  • String interpolation: backtick template literals
  • table.freeze / table.clone: immutable tables, shallow copy
  • Compound assignments: +=, -=, *=, /=, ..=
  • continue statement: in loops
  • Sandbox isolation: per-script globals, VM interrupts

When to Use What

Use rilua When

  • You need Lua 5.1.1 behavioral equivalence (WoW addon compatibility, legacy Lua code)
  • You want zero external dependencies and no C toolchain
  • You are targeting wasm32-unknown-unknown
  • Memory safety guarantees in the interpreter matter (no unsafe in core)
  • You need Send support for multi-threaded embedding
  • The embedding scenario has modest performance requirements (scripting hooks, configuration, game logic at moderate scale)

Use lua-rs When

  • You want Lua 5.5 features in a pure-Rust interpreter
  • Performance close to C Lua matters and you accept internal unsafe
  • You want async Rust function support and Serde integration
  • You want derive macros for UserData
  • Your build environment supports nightly Rust
  • You do not need Send/Sync thread safety

Use mlua When

  • You need production-grade Lua at C execution speed
  • You want async Rust integration with Lua coroutines
  • You need Serde serialization of Lua values
  • You are building a Lua module (.so/.dll) rather than embedding
  • You need multiple Lua version support (5.1 through 5.5)
  • Your build environment has a C compiler available

Use Luau When

  • You need sandboxed execution of untrusted scripts
  • Performance is critical (native codegen for hot paths)
  • You want gradual typing for large Lua codebases
  • You need Roblox ecosystem compatibility
  • You can accept the divergence from standard Lua 5.1 semantics (setfenv/getfenv removed, no __gc, no string.dump)

Use PUC-Rio Lua Directly When

  • You are writing C/C++ and do not need Rust integration
  • You need the absolute reference behavior
  • You are extending Lua at the C API level with existing C libraries