A Lua-flavored language with a stack-based virtual machine and Luau-style gradual types, written in Go.
The surface syntax tracks Lua 5.4 as closely as possible — the same chunks, the same scoping rules, the same metatables, coroutines, and standard library shape. Optional type annotations on top, à la Luau, check at compile time and erase before bytecode so the runtime is unchanged.
The implementation is a clean-room rewrite focused on being readable end-to-end: lex → parse → typecheck → bytecode → stack VM. No LLVM, no JIT, no surprises.
- Status
- Quick start
- Bundling a script into a standalone .exe
- Desktop UI module (opt-in)
- Bonsai mode
- Examples
- Type system
- REPL
- Project layout
- Non-goals (for now)
- Contributing
- Inspirations
- License
- Lexer — Lua 5.4 tokens, long-bracket strings/comments, hex/exponent numbers,
--!strict/--!nonstrict/--!nocheckmode directives. - Parser — full Lua 5.4 grammar including
goto/labels, attributes (<const>,<close>), method-call sugar, numeric and genericfor, plus Luau type-syntax (annotations, type aliases, type assertions, optionals, unions, function types, structural table types). - Type checker — gradual: untyped code is implicitly
any; annotations opt in. Primitives, function types, optionals, unions, type aliases (including structural table shapes), type assertions, generics (parametric functions, type aliases, and structs with call-site inference), structs, and tagged enums. Stdlib has hand-written signatures somath.sqrt(true)is a compile error. - Bytecode — stack-based with closure upvalues, vararg passing, generic-
foriteration, and a one-time scan that fillsNumLocalsat runtime where the generator left it blank. Types are erased before this stage — the VM never sees them. - VM — closures, metatables, coroutines (via goroutines + channels),
pcall/errorunwinding. - Standard library —
print/tostring/tonumber,ipairs/pairs/next,pcall/assert/error,typeplus thetypeof/sizeofreflection builtins, raw and metatable helpers, plusmath,string(full Lua pattern surface:find/match/gmatch/gsub),table,io.write/read,coroutine, andpackage/require.__tostringis honoured bytostring,print,io.write,error, and the REPL. - Native modules —
require("…")fordb,os,math,json,http(client),httpserver,crypto,time,regexp,uuid,sort,compression,bit32,utf8,io,log,debug,std(stack/queue/deque/set/list/heap/hashmap),clustering(k-means/DBSCAN/hierarchical/mean-shift),classification(Naive Bayes/KNN/perceptron/logistic/SVM), and the data-science set:stats(descriptive/inferential statistics),linalg(vectors/matrices),csv(read/write), anddataframe(column-oriented tables). All ship by default;cmd/natives.go::nativeRegistrarsis the single source of truth. The Fyne-backeduiGUI module is opt-in behind theluascript_uibuild tag (it pulls in OpenGL/cgo) — see Desktop UI module. - Enums —
enum Name V1, V2 enddeclares an int-auto-increment, frozen-via-__newindex-proxy table (typecheck treats the alias asnumber). Add a payload to any variant —enum Shape Circle(number), Rect(number, number), Unit end— to make it a tagged sum type: payload variants become constructors, nullary variants become singletons, and every value carries a__tagplus atypeof-visible nominal type. Bare-variant enums keep the original behaviour. - Structs —
struct Point { x: number, y: number }declares a nominal product type: a constructor value plus a type alias for the structural shape. Construct positionally (Point(1, 2)) or by name (Point{ x = 1, y = 2 }); instances report their name viatypeof.structis a soft keyword, so existingstructvariables still compile. - Match —
match subject do ... enddispatches over an expression. Value/literal arms (0,1, 2, 3,_), typed binding arms (n: number ->,x: any ->), destructuring arms for tagged enums (Shape.Circle(r) ->) and structs (Point{ x = px } ->), andifguards. A pure parser-level desugar — no runtime machinery, no new VM opcodes. - Generics —
<T, U>type parameters on functions, type aliases, and structs (function map<T, U>(xs: {T}, f: (T) -> U): {U},type Box<T> = { value: T },struct Pair<A, B> { ... }). Instantiated withName<Args>(nestedBox<Box<number>>works). Fully erased before bytecode; the checker infers type variables from call arguments and substitutes them into return types. - Defer —
defer cleanup()schedules a call to run when the enclosing function exits, in last-in-first-out order, on normal return and when an error unwinds the frame (caught bypcall). Lowered to a frame-local closure list; ideal for paired acquire/release. Capture is by upvalue, so a deferred call sees a variable's value at exit time (unlike Go, which snapshots arguments eagerly). - REPL — readline-driven, history-backed, with continuation prompts for incomplete input. Top-level
localdeclarations persist across REPL chunks (a deliberate convenience deviation fromlua). Type-check errors are surfaced with a distincttype-error:prefix.
The main package lives in ./cmd/luascript, so run the interpreter with go run ./cmd/luascript:
# Run the REPL
go run ./cmd/luascript
# Run a script
go run ./cmd/luascript examples/05_types.lsc
# Force the REPL even when a script is supplied
go run ./cmd/luascript -i examples/05_types.lsc
# Print version
go run ./cmd/luascript -v
# Disassemble a script (bytecode dump)
go run ./cmd/luascript -dis examples/01_basics.lsc
# Time the run, or re-run on every save
go run ./cmd/luascript -time examples/02_functions.lsc
go run ./cmd/luascript -watch examples/02_functions.lscBuild a binary:
go build -o luascript ./cmd/luascript
./luascript examples/01_basics.lscThe CLI dispatches a few subcommands before flag parsing:
| Subcommand | What it does |
|---|---|
luascript fmt [-w] FILE.lsc |
Format a source file (trivia-preserving). -w writes in place. |
luascript build -o OUT.exe FILE.lsc |
Bundle script + interpreter into a single .exe (see next section). |
luascript analyze FILE.lsc |
AST-level static analyzer with pluggable passes (complexity, lint, …). |
luascript profile -cpu cpu.pgo -count 50 FILE.lsc |
Collect a CPU profile suitable for PGO (scripts/build-pgo.sh consumes it). |
luascript build produces a single executable that contains both the interpreter and your script — drop it on a machine that doesn't have luascript installed and double-click it.
# Build luascript first, then have it bundle your script:
go build -o luascript ./cmd/luascript
./luascript build -o hello.exe examples/01_basics.lsc
./hello.exe # runs the embedded script| Flag | Effect |
|---|---|
-o PATH |
Output path for the bundled binary (required). |
Mechanics: the script is appended to a copy of the luascript binary along with a magic trailer. On startup the bundled .exe inspects its own tail, detects the trailer, and runs the embedded script in parser.NormalMode with the same VM and native modules the interpreter uses. Syntax is checked at bundle time, so you can't ship a broken .exe by accident.
Limitations (v1):
- The bundled binary matches the host platform — no cross-compilation flag yet.
- Bundled scripts don't see
os.Args. - Antivirus heuristics occasionally flag self-modifying-style .exes; code-signing fixes it. This is the same trade-off PyInstaller and Bun's
--compilehave.
The ui native module is a thin Lua binding over Fyne v2 for building desktop windows and widgets. Because Fyne drags in OpenGL via cgo, it is not compiled by default — a plain go run ./cmd/luascript stays pure-Go and needs no C toolchain. require("ui") still resolves in a default build; it only errors if a script actually constructs a widget, telling you to rebuild with the tag.
To enable the real GUI, build or run with the luascript_ui build tag:
# Run a UI script with the Fyne backend compiled in
go run -tags luascript_ui ./cmd/luascript examples/31_ui_module.lsc
# Build a GUI-capable binary
go build -tags luascript_ui -o luascript ./cmd/luascript
./luascript examples/31_ui_module.lscPrerequisites for the tagged build: cgo enabled (CGO_ENABLED=1, the default when a C compiler is present) and a working C toolchain plus OpenGL development headers:
- Windows — a MinGW-w64 GCC, e.g. via MSYS2:
pacman -S mingw-w64-x86_64-gcc mingw-w64-x86_64-headers mingw-w64-x86_64-crt, then ensure…\mingw64\binis onPATH. - macOS — Xcode command-line tools (
xcode-select --install). - Linux — a C compiler plus the GL/X11 dev packages (Debian/Ubuntu:
sudo apt install gcc libgl1-mesa-dev xorg-dev).
Without the tag the headless stub is used, so the rest of the language builds and runs regardless of whether a C toolchain is installed.
For a break from the language work, luascript ships with a small ASCII-bonsai grower. It is unrelated to the Lua runtime — just a fun side mode.
# Grow a tree in the alt-screen (press q or Ctrl+C to leave)
./luascript -bonsai
# Print a single tree to stdout instead
./luascript -bonsai -bonsai-print
# Animate growth step-by-step
./luascript -bonsai -bonsai-live
# Reproducible tree from a seed
./luascript -bonsai -seed 42
# Attach a message next to the tree
./luascript -bonsai -bonsai-msg "hello, world"| Flag | Effect |
|---|---|
-bonsai |
Grow an ASCII bonsai tree and exit. |
-seed N |
RNG seed for reproducible trees (0 = random). |
-bonsai-print |
Print the tree to stdout instead of staying in the alt-screen. |
-bonsai-live |
Animate growth step-by-step. |
-bonsai-msg S |
Attach a message next to the tree. |
A walk-through set lives in examples/. Most are runnable straight from the
repo root with go run ./cmd/luascript examples/<file>:
| File | What it shows |
|---|---|
01_basics.lsc |
variables, control flow, primitive values, logical operators |
02_functions.lsc |
recursion, closures and upvalues, multi-return, higher-order functions |
03_tables_and_metatables.lsc |
records, arrays, methods, operator overloading via __add, __index |
04_coroutines.lsc |
coroutine.create / resume / yield / wrap |
05_types.lsc |
the full Luau-style type surface — primitives, optionals, unions, function types, type aliases, type assertions |
06_strict_mode.lsc |
--!strict enforcement and what it rejects |
07_modules.lsc |
require, package.path, package.loaded, searchpath — imports mathx.lsc next to it |
08_stdlib.lsc |
a bundled-library set loaded via LUASCRIPT_LIB — flat modules, dotted submodules, package init files |
09_native_module.lsc |
importing a host-provided native module (internal/native/stdlib/db) |
10_os_module.lsc |
importing a host-provided native module (internal/native/stdlib/os) |
11_compounds.lsc |
compound assignment operators (x op= e) |
12_math_module.lsc |
the math native module |
13_json_module.lsc |
the json native module |
14_match.lsc |
match statement basics — value/literal patterns, multi-pattern arms, _ wildcard (a parser-level desugar) |
15_http_module.lsc |
the http client native module (shortcuts, http.request{...}, stateful clients) |
16_httpserver_module.lsc |
the httpserver native module — handlers, :listen / :stop |
17_crypto_module.lsc |
the crypto native module — hashing, HMAC, random bytes |
18_time_module.lsc |
the time native module — durations, timers, formatting |
19_regexp_module.lsc |
the regexp native module (Go regex; :capture, not :match) |
20_uuid_module.lsc |
the uuid native module |
21_sort_module.lsc |
the sort native module — sort.sort / stable / reverse / is_sorted |
22_string_interpolation.lsc |
backtick string interpolation: `hello {name}` desugars to ..-concat |
23_io.lsc |
the full Lua-5.4 io library (file handles, :read/:write/:lines/:seek) |
24_bit_utf8.lsc |
the bit32 and utf8 native modules |
25_os_full.lsc |
the expanded os parity surface (date, time, clock, execute, rename, tmpname, setlocale) |
26_patterns.lsc |
full Lua-pattern surface (find/match/gmatch/gsub with %a %d %w classes, () captures, %b() balanced, %f[set] frontier) |
27_debug_module.lsc |
the debug native module — traceback, getinfo, hook stubs |
28_compression_module.lsc |
the compression native module — gzip, zlib, deflate, run-length (rle_encode/rle_decode) |
29_enums.lsc |
enum Name V1, V2 end — int-auto-increment, frozen via __newindex proxy |
30_std_module.lsc |
the std native module — stack, queue, deque, set, list, heap (requires cmp), hashmap |
31_ui_module.lsc |
the ui desktop module (Fyne) — windows, widgets, layouts. Run with -tags luascript_ui (see Desktop UI module) |
32_defer.lsc |
defer call() — LIFO cleanup that runs on normal return, fall-off-end, and error unwinding |
33_typeof_sizeof.lsc |
the typeof / sizeof reflection builtins — int/float distinction, __type metatable hook, byte/entry sizes |
34_clustering_module.lsc |
the clustering native module — k-means (k-means++ seeding), DBSCAN, hierarchical/agglomerative, mean-shift |
35_classification_module.lsc |
the classification native module — Naive Bayes (text), KNN, perceptron, logistic regression, SVM (linear + RBF kernels) |
36_math.lsc |
the math native module in depth — Lua 5.4 scalar surface plus statistics helpers (mean/variance/standard_deviation/softmax) |
37_stats_module.lsc |
the stats native module — descriptive/inferential statistics (median, mode, quantiles, iqr, covariance, correlation, skew/kurtosis, zscore/normalize, describe) |
38_linalg_module.lsc |
the linalg native module — vectors and matrices (dot, norm, matmul, transpose, det, inverse, solve) |
39_csv_module.lsc |
the csv native module — parse/stringify/read/write with header + numeric coercion and custom delimiters |
40_dataframe_module.lsc |
the dataframe native module — column-oriented tables (select/filter/with_column/sort/group_by/describe/to_csv, pretty print) |
41_ml_module.lsc |
the ml native module — a feed-forward neural-network engine (build a topology, train on labelled data, predict) |
42_structs.lsc |
struct Name { field: T } — nominal product types, positional and named construction, typeof, typed parameters, optional fields, nesting |
43_tagged_enums.lsc |
tagged sum-type enums — payload variants as constructors, nullary variants as singletons, __tag/typeof introspection, a Result pattern |
44_match.lsc |
match v2 — typed binding patterns, if guards, and destructuring of tagged enums and structs (with a Result pipeline) |
45_generics.lsc |
generics — parametric functions with inference (map/filter), generic type aliases (Box<T>), and generic structs (a Stack<T>) |
require resolves a module name against package.path. The two entry kinds
that matter for these examples, searched in this order:
-
The directory of the script being run — added automatically. So a module sitting next to your script is always found, no matter which directory you launched from. This is why
07_modules.lscjust works:go run ./cmd/luascript examples/07_modules.lsc # mathx.lsc is found next to it -
LUASCRIPT_LIB— a bundled-library root, read once at startup. It is not on the path unless you set it.08_stdlib.lscis the demo for exactly this: its modules live underexamples/stdlib/(not next to the script). For convenience the example self-bootstrapspackage.pathsogo run ./cmd/luascript examples/08_stdlib.lscworks without setting the env var, but the canonical invocation is still:# bash LUASCRIPT_LIB=./examples/stdlib go run ./cmd/luascript examples/08_stdlib.lsc # PowerShell $env:LUASCRIPT_LIB="./examples/stdlib"; go run ./cmd/luascript examples/08_stdlib.lsc # cmd.exe set LUASCRIPT_LIB=./examples/stdlib && go run ./cmd/luascript examples/08_stdlib.lsc
LUASCRIPT_LIBis resolved relative to your current working directory — if you run from somewhere other than the repo root, adjust the path accordingly (e.g.../examples/stdlibfrom insidecmd/).
Between the two, the plain cwd-relative entries (./?.lsc, ./src/?.lsc,
...) are searched as well, so a module under your working directory is still
found even when it sits nowhere near the script.
The native-module examples pull their modules from the host via
package.preload, so they need neither a path entry nor LUASCRIPT_LIB.
A taste, in case you don't want to open files:
-- factorial
local function fact(n)
if n <= 1 then return 1 end
return n * fact(n - 1)
end
print(fact(10)) -- 3628800
-- closures + upvalues
local function counter()
local n = 0
return function()
n = n + 1
return n
end
end
local next = counter()
print(next(), next(), next()) -- 1 2 3
-- coroutines
local co = coroutine.create(function()
for i = 1, 3 do coroutine.yield(i) end
end)
print(coroutine.resume(co)) -- true 1
print(coroutine.resume(co)) -- true 2
-- types
type Point = { x: number, y: number }
local function dist(p: Point): number
return math.sqrt(p.x * p.x + p.y * p.y)
end
print(dist({ x = 3, y = 4 })) -- 5.0LuaScript's type system is gradual in the Luau sense: annotations are optional, untyped code is treated as any, and any flows into and out of any typed slot.
-- Annotations on locals, parameters, returns. Untyped slots stay any.
local count: number = 42
local name: string = "Ada"
local maybe: string? = nil -- T? ≡ T | nil
local id: number | string = "user-7" -- unions
-- Function types — params, returns, multi-return, varargs.
local function add(a: number, b: number): number
return a + b
end
local function pair(x: number): (number, number)
return x, x * 2
end
-- Type aliases — including structural table shapes.
type Point = { x: number, y: number }
type Callback = (number) -> string
type Numbers = { number } -- array shorthand for {[number]: number}
local origin: Point = { x = 0, y = 0 }
-- Type assertions: programmer-controlled cast. Runtime is a no-op.
local raw: any = 7
local n: number = raw :: numberstruct declares a nominal product type — a fixed, typed set of fields plus a
constructor. The name doubles as a type alias for the structural shape, so
: Point annotations check field access. struct is a soft keyword (only
special in struct <Name> position), so it remains a legal identifier
elsewhere.
struct Point { x: number, y: number }
local p = Point(3, 4) -- positional
local q = Point{ x = 3, y = 4 } -- named (order-independent)
print(typeof(p)) -- Point
print(p.x + q.y) -- 7
local function mag(pt: Point): number
return math.sqrt(pt.x * pt.x + pt.y * pt.y)
endFields typed T? may be omitted in named construction. The checker rejects
missing required fields, unknown fields, and type mismatches.
Give any enum variant a payload and the whole enum becomes a tagged union:
payload variants become constructors, nullary variants become singleton
values. Each value carries a __tag (the variant name) and a typeof-visible
nominal type. Bare-only enums keep the classic integer form (see
29_enums.lsc).
enum Shape
Circle(number),
Rect(number, number),
Unit,
end
local c = Shape.Circle(5) -- constructor
local u = Shape.Unit -- singleton value
print(typeof(c), c.__tag, c[1]) -- Shape Circle 5match dispatches over a subject once. On top of value/literal patterns it
supports typed binding patterns, destructuring of tagged enums and structs, and
if guards. It is a parser-level desugar — no runtime cost.
local function area(s: Shape): number
match s do
Shape.Circle(r) -> return 3.14159 * r * r -- destructure payload
Shape.Rect(w, h) -> return w * h
Shape.Unit -> return 0
_ -> return -1
end
return -1
end
local function describe(v): string
match v do
n: number if n < 0 -> return "negative" -- typed binding + guard
n: number -> return "number " .. n
s: string -> return "string of " .. #s
Point{ x = px } -> return "point at x=" .. px -- struct destructure
x: any -> return "other"
end
return "?"
endValue/literal patterns (0, 1, 2, 3, Color.RED, _) work exactly as
before. x: any binds unconditionally; guards fall through to the next arm on
failure.
Type parameters <T, U> attach to functions, type aliases, and structs.
They are erased before bytecode; precision comes from inference — the checker
unifies a call's arguments against the declared parameter types and substitutes
the result into the return type.
local function identity<T>(x: T): T
return x
end
local n: number = identity(5) -- ok: T inferred = number
-- local s: string = identity(5) -- error: T inferred number, not string
local function map<T, U>(xs: {T}, f: (T) -> U): {U}
local out = {}
for i, x in ipairs(xs) do out[i] = f(x) end
return out
end
type Box<T> = { value: T } -- generic alias
type Nested = Box<Box<number>> -- instantiate; nested `>>` is handled
struct Pair<A, B> { first: A, second: B } -- generic struct
local pr = Pair(1, "one") -- A = number, B = string inferredInside a generic body a type variable is opaque but gradual, so parametric code never produces spurious errors; the concrete types are pinned at each call site.
A leading --!strict, --!nonstrict, or --!nocheck on the first line of a file controls how strictly that file is checked.
| Directive | Effect |
|---|---|
| (none) | Default. Gradual checking. |
--!strict |
Implicit-any parameters become errors. |
--!nonstrict |
Same as the default. Useful for explicitness. |
--!nocheck |
Skip the type pass for this file entirely. |
- Intersection types (
A & B) - String-singleton types (
"foo" | "bar") - Cross-module type checking —
require()returnsany - Recursive type aliases (the parser accepts them; the resolver doesn't)
These are explicitly named in error messages where relevant, so users hit a clear wall instead of silent miscompiles.
Generics are supported now (parametric functions, type aliases, and structs with call-site inference) — see Generics.
Launch with go run ./cmd/luascript (no arguments). Built-in commands:
| Command | Effect |
|---|---|
help |
print the help screen |
exit, quit |
leave the REPL |
reset |
rebuild the VM (clears all globals and user state) |
clear |
clear the screen |
Key bindings: Ctrl+C cancels the current input, Ctrl+D exits, Ctrl+R searches history.
Bare expressions print their value:
luascript » 1 + 2
=> 3
luascript » {1, 2, 3}
=> table: 0xc000...
Top-level local persists across REPL chunks (it's promoted to a global at compile time so subsequent inputs can read it):
luascript » local greeting = "hi"
luascript » print(greeting)
hi
Inside any nested scope (do/if/for/function body) local keeps standard Lua semantics.
Incomplete input opens a continuation prompt:
luascript » function double(x)
... return x * 2
... end
luascript » print(double(21))
42
Type errors land with a distinct prefix so they're easy to spot:
luascript » local x: number = "hi"
type-error: Type "string" could not be converted into "number" at line 1
.
├── cmd/
│ └── luascript/ CLI entrypoint (main.go) + `luascript build` bundler
├── internal/ implementation packages (not a public API)
│ ├── compiler/
│ │ ├── lexer/ token stream from source text
│ │ ├── token/ token types and keyword table
│ │ ├── parser/ recursive-descent parser, Pratt-style for expressions
│ │ ├── ast/ AST node definitions (statements, expressions, types)
│ │ ├── typecheck/ gradual type system — Type representation, env, pass
│ │ ├── optimize/ AST constant-folding pass (Lua-5.4-safe subset)
│ │ ├── analyze/ pass-registry static analyzer (`luascript analyze`)
│ │ ├── debug/ pprof Start/Stop wrappers used by `luascript profile`
│ │ ├── bytecode/ AST → instruction-set generator
│ │ └── compiler.go top-level pipeline (lex → parse → typecheck → optimize → bytecode)
│ ├── vm/ stack VM, closures, metatables, coroutines, stdlib
│ ├── native/ bundled native modules
│ │ ├── stdlib/ runtime modules (db, os, http, json, std, log, …)
│ │ └── datascience/ ndarray, dataframe, stats, linalg, ml, plot, …
│ ├── lsp/ language server (protocol, jsonrpc2, uri + server/)
│ ├── formatter/ `luascript fmt` — trivia-preserving formatter
│ ├── bonsai/ ASCII bonsai tree side mode (cbonsai/gobonsai fork)
│ ├── repl/ interactive REPL (readline + engine wrapper)
│ ├── pkgmanager/ package manifest / lockfile / fetch
│ ├── gctune/ GC tuning helpers
│ └── version/ version string
├── examples/ runnable .lsc programs that double as tutorials
├── scripts/ helper scripts (`build-pgo.sh`, `benchmark.rb`)
└── assets/ logo and static assets
The compiler is designed so each stage is independently testable and the AST is the only contract between parser, type checker, and bytecode generator. The VM never sees source text or types; the parser never sees instructions.
Run the full test suite before sending a change:
go test ./...Tests live next to the code they cover (*_test.go). The bytecode tests in particular are useful: they assert exact opcode sequences for representative source snippets, which catches accidental codegen drift early. The type checker has its own focused suite under internal/compiler/typecheck/checker_test.go.
- Lua 5.4 — the syntax and semantics target.
- Luau — the type-system shape.
- Goby — the original stack VM and bytecode-generator scaffolding (this project is a Goby fork in spirit, though much has been rewritten).
See LICENSE.
