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.
- 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. 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, 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. - 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, so run the interpreter with go run ./cmd:
# Run the REPL
go run ./cmd
# Run a script
go run ./cmd examples/05_types.lsc
# Force the REPL even when a script is supplied
go run ./cmd -i examples/05_types.lsc
# Print version
go run ./cmd -vBuild a binary:
go build -o.lsc ./cmd
..lsc examples/01_basics.lsc.lsc build` produces a single executable that contains both the interpreter and your script — drop it on a machine that doesn't have.lsc installed and double-click it.
# Build.lsc first, then have it bundle your script:
go build -o.lsc ./cmd
..lsc 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.lsc binary along with a 18-byte 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.
For a break from the language work, .lsc` 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)
..lsc -bonsai
# Print a single tree to stdout instead
..lsc -bonsai -bonsai-print
# Animate growth step-by-step
..lsc -bonsai -bonsai-live
# Reproducible tree from a seed
..lsc -bonsai -seed 42
# Attach a message next to the tree
..lsc -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 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 .lsc_LIB — flat modules, dotted submodules, package init files |
09_native_module.lsc |
importing a host-provided native module (native/db) |
10_os_module.lsc |
importing a host-provided native module (native/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_window.lsc |
the window GUI module (Fyne backend; -tags.lsc_no_window to opt out) |
15_io.lsc |
the full Lua-5.4 io library (file handles, :read/:write/:lines/:seek) |
16_bit_utf8.lsc |
the bit32 and utf8 native modules |
17_os_full.lsc |
the expanded os parity surface (date, time, clock, execute, rename, tmpname, setlocale) |
18_patterns.lsc |
full Lua-pattern surface (find/match/gmatch/gsub with %a %d %w classes, () captures, %b() balanced, %f[set] frontier) |
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 examples/07_modules.lsc # mathx.lsc is found next to it -
.lsc_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), so it needs .lsc_LIB` pointed there. Run it from the repo root:# bash .lsc_LIB=./examples/stdlib go run ./cmd examples/08_stdlib.lsc # PowerShell $env.lsc_LIB="./examples/stdlib"; go run ./cmd examples/08_stdlib.lsc # cmd.exe set.lsc_LIB=./examples/stdlib && go run ./cmd examples/08_stdlib.lsc
.lsc_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 (09, 10, 12, 13) pull their modules from
the host via package.preload, so they need neither a path entry nor
.lsc_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 :: numberA 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. |
- Generics (
function f<T>(x: T): T) - Intersection types (
A & B) - Type refinements (narrowing inside
if type(x) == "string") - 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.
Launch with go run ./cmd (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:
.lsc » 1 + 2
=> 3
.lsc » {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):
.lsc » local greeting = "hi"
.lsc » print(greeting)
hi
Inside any nested scope (do/if/for/function body) local keeps standard Lua semantics.
Incomplete input opens a continuation prompt:
.lsc » function double(x)
… return x * 2
… end
.lsc » print(double(21))
42
Type errors land with a distinct prefix so they're easy to spot:
.lsc » local x: number = "hi"
type-error: Type "string" could not be converted into "number" at line 1
.
├── 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
│ ├── bytecode/ AST → instruction-set generator
│ └── compiler.go top-level pipeline (lex → parse → typecheck → bytecode)
├── vm/ stack VM, closures, metatables, coroutines, stdlib
├── repl/ interactive REPL (readline + engine wrapper)
├── examples/ runnable .lsc programs that double as tutorials
├── version/ version string
└── cmd/ CLI entrypoint (main.go) + .lsc build` bundler
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.
- The
debuglibrary. - Garbage-collection metamethods (
__gc,__closeenforcement).
These are deliberate omissions, not bugs — they're listed so contributors know what's out of scope rather than wondering whether to file an issue.
Previously listed but now shipped: Lua patterns, io.open + full file-handle stdlib, expanded os.
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 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.