A cargo-style toolchain for Lua. One static binary that gives Lua the
workflow Rust developers expect — check, lint, fmt, build, doc,
lsp — with a type checker that speaks stock
LuaCATS annotations (the
lua-language-server dialect) but verifies what luals only trusts:
---@class conformance, ---@generic generics, and types shared across
modules and packages. Works with Lua 5.1–5.4 and LuaJIT. It is a purely
static toolchain: it never spawns an interpreter, and it consumes a
rock tree rather than producing one — you materialize lua_modules/ with
luarocks and luabox reads it. Not a runtime, not a package manager.
Install the binary (see Install below), then:
luabox new hello
cd helloluabox new scaffolds a project — a luabox.toml manifest, a
hello-0.1.0-1.rockspec, a .gitignore, and src/main.lua:
Created binary project `hello` (edition 5.4)
The two manifests split by ownership, and neither is generated from the
other. luabox.toml is luabox's own configuration — edition, [build],
[types], [lint]. The rockspec is the ecosystem's manifest, the one
luarocks reads: your package's name, version, and dependencies live there,
so anyone can luarocks install your project without luabox. luabox scaffolds
it and then leaves it alone — it neither edits nor resolves it.
Write an annotated function in src/main.lua. The ---@param / ---@return
comments are ordinary LuaCATS — the same annotations lua-language-server reads:
---@param name string
---@return string
local function greet(name)
return "Hello, " .. name .. "!"
end
print(greet("world"))
print(greet(42))Typecheck it. luabox catches the 42 — a plain Lua editor would not:
$ luabox check
error[LB0300]: type mismatch: expected `string`, found `42`
--> src/main.lua:8:13
|
8 | print(greet(42))
| ^^ expected `string`
check: 1 errors, 0 warnings in 1 files
Fix line 8 to print(greet("luabox")) and it passes:
$ luabox check
check: 0 errors, 0 warnings in 1 files
Format it, lint it:
$ luabox fmt
formatted 1 files (0 changed)
$ luabox lint
lint: 0 errors, 0 warnings in 1 files
That is the whole loop — one binary for check, format, and lint, no build
config, and no interpreter anywhere: luabox reads your sources, it never
executes them. Run the program with whatever Lua you already have. See
examples/ for larger, real projects —
a LÖVE game, a multi-package workspace, and a 5.4-to-5.1 cross-version lowering
demo.
Prebuilt binaries are attached to every tagged
GitHub release (v*, built
by .github/workflows/release.yml — see
RELEASING.md). The one-line installers fetch the latest one:
# Linux / macOS
curl -fsSL https://raw.githubusercontent.com/flying-dice/luabox/main/scripts/install.sh | bash# Windows
irm https://raw.githubusercontent.com/flying-dice/luabox/main/scripts/install.ps1 | iexEach release ships prebuilt binaries for Linux x86_64, macOS Apple
Silicon, and Windows x86_64, with SHA256SUMS alongside them. Already
installed? luabox upgrade self-updates from the latest release.
To build from source instead — or to track the tip of main:
cargo install --git https://github.com/flying-dice/luabox luabox-cli
# or, from a checkout:
cargo build --release # target/release/luaboxLua's ecosystem already has rich type annotations — LuaCATS, as read by lua-language-server. luabox's edge is that it treats those annotations as claims to be verified, not hints to be trusted, on the exact same format:
---@classconformance. A---@class Dog : Animalmust actually provideAnimal's fields and methods with compatible signatures — missing members are errors, not silent gaps.- Real generics.
---@generic Tfunctions and generic classes are monomorphized:id(42)returnsinteger, and flowing that into astringslot is caught. - Cross-module and cross-package types. A
---@classdeclared in one file is checked at every use site across the workspace; a dependency's types (shared via[types] defs) are visible and checked in the consumer. - Undefined globals and fields. Typo'd globals and unknown fields on declared classes are flagged.
All on stock LuaCATS — there is no second, luabox-specific type file format. See DIRECTION.md for the governing decision record and SPEC.md for the full design.
Editor integrations live in their own repos and release independently:
| Editor | Repo | Install |
|---|---|---|
| VS Code | flying-dice/luabox-vscode | .vsix from that repo's releases → code --install-extension |
| JetBrains | flying-dice/luabox-jetbrains | plugin .zip from that repo's releases → install from disk |
Both wrap the luabox lsp stdio language server (diagnostics with
quick-fixes, completion with auto-require imports, hover, goto
definition/type-definition/implementation, find-references, rename, document
& workspace symbols, signature help, call hierarchy, inlay hints, semantic
tokens, formatting, folding and selection ranges; .lua files), resolving
the luabox binary from PATH (overridable in settings). Neither is on its
marketplace yet
(#102).
Any other editor can point its LSP client at luabox lsp.
luabox is alpha software — dependency management and running code are
deliberately out of scope (see
DIRECTION.md), and the
editor extensions are not yet on their marketplaces (each ships installable
artifacts from its own repo's releases). The full LuaCATS tag vocabulary is
enforced.
Every remaining gap is
documented honestly in
LIMITATIONS.md. Read it before you rely on luabox for
anything load-bearing. Where two ---@class declarations for one name
disagree — two blocks, two files, or two parents — the winner is tabulated
per member kind in the
class-merge precedence matrix,
including which cells lua-language-server agrees with.
init / new |
scaffold a project (--lib, --edition 5.1..5.4|luajit) |
check |
typecheck: LuaCATS + rich inference, dialect legality, goto/label/break legality, --target, --watch, --format json|sarif|github|gitlab |
fmt |
canonical formatter for .lua (--check, --watch) |
lint |
type-informed rules, ---@luabox-ignore, per-rule [lint] levels, --fix, --format json|sarif|github|gitlab |
build |
one tsc/esbuild-style emit driven by [build]: lower edition → target (goto, bitops, <close>, _ENV, …) with tree-shaken polyfills; bundle = true inlines the require graph into one file per entry (--minify, --sourcemap); mode = love|nvim-plugin packages a .love / Neovim plugin. Flags (--target/--out/--outfile/--entry/--bundle/--no-bundle/--sourcemap/--minify/--mode) override config |
unmap |
decode a production traceback back to source lines via the <bundle>.map that build --sourcemap writes next to the bundle |
upgrade |
self-update from GitHub releases (luabox upgrade for latest, or a specific v0.1.1), checksum-verified |
lsp |
language server: diagnostics + quick-fixes, completion (auto-require), hover, goto def/type/impl, references, rename, symbols, signature help, call hierarchy, inlay hints, semantic tokens, formatting |
doc |
static docs from annotations |
explain LBnnnn |
rustc-style diagnostic pages |
schema |
print the JSON Schema (draft 2020-12) for luabox.toml — point an editor, a validator or an LLM at the whole manifest contract |
build --mode loveneeds an external zip tool onPATH. A.lovefile is a zip archive and luabox carries no zip implementation, so packaging is the one build step that shells out:zip, elsepython3 -m zipfile, elsepython -m zipfileon Linux/macOS; the System32bsdtar, else PowerShell'sCompress-Archive, on Windows. With none of them available the build fails loudly, naming every tool it tried — it never leaves a partial.lovebehind. It archives the output luabox just emitted; no Lua is executed. No other build step spawns anything,mode = "nvim-plugin"included — across the whole CLI the only commands that start a child process are this packaging step,upgrade, anddoc --open(see DIRECTION.md).
luabox.toml is described in full by a JSON Schema (draft 2020-12) that the
binary carries — every table, every key, every default, every closed
vocabulary (edition, build.target, build.mode, lint levels) and every
mutually-exclusive dependency form, each with a prose description. Write it
out and point your tooling at it:
luabox schema > luabox.schema.jsonEditors and validators get completion and inline errors for the manifest; an
LLM handed that one file can write a correct luabox.toml without guessing.
The schema describes the manifest's data model — you write TOML, tooling
maps it to JSON with the standard mapping and validates that.
It cannot go stale: the schema is generated from the same declarative key
table luabox validates manifests against, so its key sets, enums and
required fields are the parser's own rather than a copy of them. What that
table cannot express — above all the mutually exclusive dependency source
forms — is held in place by running every example manifest plus a curated
valid/invalid corpus through both the schema and luabox itself, demanding
the same verdict from each.
A minified bundle's tracebacks name the bundle's lines, not your source.
Enable source maps at build time and keep the .map around to decode
production crashes back to their real file and line:
[build]
bundle = true
outfile = "dist/game.lua"
minify = true
sourcemap = true # writes dist/game.lua.map next to the bundleluabox build # emits dist/game.lua + dist/game.lua.map
# ship dist/game.lua to players; keep dist/game.lua.map in your build artifactsWhen a player pastes a traceback like dist/game.lua:842: attempt to index a nil value, pipe it back through unmap (map is read from <bundle>.map next
to the bundle):
echo 'dist/game.lua:842: attempt to index a nil value' | luabox unmap dist/game.lua
# → src/player.lua:10: attempt to index a nil valueThe traceback can come from stdin (above) or as trailing arguments. The map is
recorded only at build time, so sourcemap = true is what makes this possible
— there is no way to reconstruct it after the fact.
luabox consumes a rock tree; it does not produce one. Materialize a
project-local lua_modules/ with luarocks yourself, and luabox reads it:
luarocks install --tree lua_modules penlight
luabox check # penlight is requirable, bundlable, typed, and skipped as sourceAnything under lua_modules/ is on the module path for require resolution
and bundling. Both dependency layouts are searched: the luarocks tree
(lua_modules/share/lua/<X.Y>/pl/tablex.lua, where <X.Y> is your [build] target — 5.1 for luajit) and the flat lua_modules/<name>/ layout a
hand-vendored or sibling package has. A compiled C module
(lua_modules/lib/lua/<X.Y>/*.so) cannot be inlined into a text bundle, so
require calls naming one are left as runtime requires — ship the .so
alongside your bundle. lua_modules/ is never walked as project source:
check, lint, fmt and build skip it whatever it contains.
For release builds, opt into strict literal-module resolution:
luabox build --bundle --strict-bundle --external socket.core --external lfsWithout --strict-bundle, unresolved literal requires retain the existing runtime
behavior. Strict mode fails with the importing file and module name, including
transitive imports. Repeat --external for intentional runtime modules; names
match exactly (no glob or prefix matching). This allowlist does not prevent a
local Lua implementation from being bundled. Strict mode applies to plain,
LÖVE and Neovim bundles, requires bundle output, and cannot be combined with
--no-bundle. Dynamic requires still fail under the existing bundler policy.
These options are CLI-only; they do not change the manifest schema.
An installed rock's types come along too, with no configuration. A rock
that documents itself for lua-language-server has already written the
signatures, and they ship inside it — so luabox check reads the installed
sources under lua_modules/share/lua/<X.Y>/ for their LuaCATS surfaces:
every ---@class, ---@enum and ---@alias they declare, plus the type a
require of each module evaluates to. No [dependencies] entry, no
per-package luabox.toml, no [types] defs. The rock's classes become
nameable and enforced in your code, local m = require("rock") carries the
module's annotated return types, and misuse is reported at your use site.
The editor reads those surfaces through the same resolver the type pass
does, so a require binding hovers and completes with the type CI checks
it against (#54): local m = require("rock") shows the module's export
type, m.helper shows that member's, and m. offers the members. Two
things are unknown on both sides, by design rather than by omission — a
dynamic require(name), and require("a") or require("b"), name no module
statically. A module whose export is a ---@class closes too, in both of
its spellings (#56): the export crosses the require boundary as the class
it carries, and hover and completion resolve the class's members through
the same merged ambient environment luabox check enforces — so a carrier
or instance export hovers as the class name, its members hover with their
declared types, completion offers them, and CI rejects what neither
declares. One shape is excepted, in the editor and in CI alike: a generic
class (---@class Box<T>) has no name that carries its type arguments, so it
crosses as a structural template — its declared members type correctly, but
an undeclared one is accepted rather than rejected. The measured rows are in
Known limitations. Naming the class
directly (---@param p Point) is equivalent — class names are
workspace-global, so the require is not what carries the type.
Surfaces only — a vendored body is never typechecked. A type error inside a rock is not your problem and produces nothing; a rock source that does not parse is skipped in silence. Two things still need definitions of your own:
- a rock with no LuaCATS annotations — there is nothing to harvest, so it
stays
unknownexactly as before (which also means an un-annotated, dynamically-built module table never becomesundefined-fieldnoise); - a library whose API is a global rather than a module return — a
LÖVE-style framework — which is what a
defs/package is for.
Write those definitions into your project's defs/ and list them in [types] defs. That also overrides a rock's own declaration: your declaration of a
name wins outright, so a wrong or incomplete annotation upstream is something
you can fix locally. Details in
decisions/09.
The flat lua_modules/<name>/ layout keeps its own route, unchanged: a
[dependencies]/[dev-dependencies] entry naming the package, a
luabox.toml for it at lua_modules/<name>/luabox.toml (or at the path you
gave) with a [types] defs key, and the defs/ directory it names — the
luals workspace.library model for packages that carry a luabox manifest.
Your *.rockspec and luarocks own dependency management — there is no
solver, no lockfile, and no registry client in luabox
(DIRECTION.md).
Cargo workspace, one crate per bounded context (SPEC.md §16):
| Crate | Owns |
|---|---|
luabox-syntax |
lossless parser: Lua dialects + LuaCATS annotations |
luabox-hir |
desugared IR, name resolution |
luabox-types |
LuaCATS type IR, inference |
luabox-db |
incremental query database |
luabox-lower |
target lowering + polyfills |
luabox-bundle |
require-graph, tree-shake, minify, sourcemaps |
luabox-manifest |
luabox.toml manifest model + project layout: discovery, source walk, [types] defs |
luabox-lsp |
language server |
luabox-cli |
the luabox binary |
cargo build
cargo test --workspace # unit + cucumber acceptance tests
cargo fmt --all --check
cargo clippy --workspace --all-targetsAcceptance tests are Gherkin feature files under
crates/luabox-cli/tests/features/ driving the real binary against temp-dir
fixture projects — the executable spec (SPEC.md §16.2).
0.2.0 — unreleased; the v1 scope cut (see DIRECTION.md). Last released: 0.1.4 (2026-07-14). The kept command surface works end to end. Alpha quality: the executable spec drives the real binary through cucumber scenarios, perf gates block CI, and lowering is verified by differential execution against real runtimes in CI. Prebuilt binaries are attached to each GitHub release; editor extensions release from their own repos; not yet published to a package registry (crates.io, Homebrew, etc.). Luau is explicitly out of scope. See LIMITATIONS.md for known gaps.
MIT — see LICENSE.