How the implementation is put together and, where it matters, why. This is the document to read before changing the compiler or the VM. For usage, see README.md; for contribution mechanics, see CONTRIBUTING.md.
- Goals and non-goals
- The pipeline
- Compiler stages
- What is desugared, and where
- The type system
- The VM
- try / catch
- Concurrency: the one rule
- Native modules
- Module resolution
- Bytecode serialization and the compile cache
- Bundled executables
- Documentation as data
- Tooling around the core
- Repository layout
- Testing and verification
LuaScript tracks Lua 5.4 semantics and adds Luau-style gradual types. Three constraints shape almost every decision below:
- Readable end-to-end. A clean-room lex → parse → check → optimize → bytecode → stack-VM pipeline. No LLVM, no JIT, no generated parser.
- Types are compile-time only. They are erased before bytecode generation. The VM has no notion of a type annotation, so the runtime is exactly the runtime you would have without the type system.
- Each stage is independently testable, and the AST is the only contract between parser, checker, and generator. The VM never sees source text or types; the parser never sees instructions.
Non-goals are listed inline in the sections they belong to, and collected under Deliberately out.
internal/compiler/compiler.go::CompileToInstructionsWith is the single
contract between stages:
source ──► lexer ──► parser ──► constcheck ──► typecheck ──► optimize.Fold ──► bytecode.Generator ──► VM
│ │
│ └─ gated by --!strict / --!nonstrict / --!nocheck
└─ always on (NOT disabled by --!nocheck)
constcheck running unconditionally is deliberate: local x <const> is a
scoping guarantee, not a type-checking nicety, so --!nocheck must not be able
to switch it off.
| Package | Responsibility | Notes |
|---|---|---|
lexer/ |
Lua 5.4 tokens, long-bracket strings/comments, mode directives | Stamps Token.Column for error reporting |
token/ |
Token kinds + keyword table | |
parser/ |
Recursive-descent, Pratt for expressions | Parses Lua 5.4 and Luau type syntax |
constcheck/ |
Rejects assignment to <const> / <close> locals |
Always on; scope-tracking |
typecheck/ |
Gradual type system | Erased after this point |
optimize/ |
AST constant folding | Lua-5.4-safe subset |
bytecode/ |
AST → instruction set | Typed A/B/StrA/BoxedAny fields |
parser.New()returns an un-primed parser. Sub-parsers built outsideParseProgrammust callsub.nextToken()twice before parsing, orparseExpressionsilently returns nil.- Errors use a construct-aware format —
<construct>: <expected> got <found> at line N, column C— plus an indentedhint:, all funnelled througherrorAt(tok, category, construct, msg, hint). Parser.loopDepthgates bothbreakandcontinue. Function bodies save and zero it, so neither escapes a function boundary (matching Lua/Luau).- Hard keywords:
match,enum,defer,try,catch,throw. Contextual keywords:type,struct,continue. A contextual keyword is recognized only when the next token can't extend it into an expression (peekStartsSuffix()), socontinue = 1andcontinue()still parse as identifiers. matchis hard, but permitted as a field/method name after./:viacurTokenIsFieldName()— otherwisestring.matchwould not parse.catchis a block terminator (alongsideend/else/until). That is what lets atrybody end without ado/endof its own, with a singleendclosing the whole statement. The error binding is optional but thedois not: a handler body can itself start with an identifier, socatch <Name> dovscatch dois the only unambiguous split.- Match destructure patterns are gated on names declared in the chunk
(
Parser.structNames/Parser.enumVariants).Circle(r)destructures positionally only whenCircleis a payload-carrying tagged-enum variant, andPoint{ x = a }only whenPointis a struct. Any other call-shaped pattern is a value pattern (call + compare).
The live match representation is the Kind-tagged ast.MatchPattern /
ast.MatchStmtArm / ast.MatchStatement in ast/statements.go. Every consumer
(parser, typecheck, constcheck, optimize, bytecode, formatter, analyze) uses it.
A recurring question is whether a construct is "real". This table is the answer:
| Construct | Representation | Where it is lowered |
|---|---|---|
Compound assign (+= -= *= /= |= &= <<= >>=) |
none | parser desugar |
| Backtick string interpolation | none | parser desugar (to ..) |
match statement |
real AST node | bytecode generator |
enum |
real AST node | bytecode generator → __enum_freeze |
continue |
real AST node | jumps to the loop's continueAnchor |
| If expressions | real AST node | jump chain; folded when the condition is literal |
| Default parameters | TypedParam.Default |
codegen prologue |
defer |
real | frame-local closure list |
try / catch / throw |
real AST nodes | dedicated opcodes (see below) |
Type assertions (x :: T) |
real AST node | erased — runtime no-op |
Notes on the non-obvious ones:
enumlowers tolocal Name = __enum_freeze({V1=1, V2=2, ...}, "Name"). The__enum_freezeglobal comes frominternal/native/stdlib/enumrt— an int-auto-increment table frozen behind a__newindexproxy.enumrtsits innativeRegistrarspurely so the helper reaches both VM code paths; it is not arequiretarget. Typecheck treats a bare enum alias asnumber.continuejumps to the anchor right after the body, before the per-iterationCloseUpvaluesand the condition/step re-check.breakcloses upvalues too — every loop form re-emitsCloseUpvalueson its exit path. Inrepeat,continuejumps to theuntilcondition, so anuntilthat reads a local declared after acontinueis a compile error (as in Luau), enforced bycheckRepeatContinueLocals.- Default parameters compile to the equivalent of
if x == nil then x = expr end, sofalsedoes not trigger the default, and a default can see earlier parameters. The checker widens a defaulted parameter withnilin the signature (callers may omit it) while binding the declared type inside the body. defercaptures by upvalue, so a deferred call observes a variable's value at exit time — unlike Go, which snapshots arguments eagerly.
bytecode/statement_generation.go::isReplTopLevel() rewrites chunk-root
local x = v (and local function f) to SetGlobal when running in
parser.REPLMode, so bindings survive across REPL chunks. Locals in any nested
scope are untouched and keep standard Lua semantics. This is why the bundled-exe
path must use parser.NormalMode — REPLMode would leak a script's top-level
locals into globals.
Gradual in the Luau sense: annotations are optional, unannotated slots are
any, and any flows into and out of any typed slot. Stdlib signatures are
hand-written in typecheck/stdlib_types.go. Table literals are inferred as
any so dynamic Lua patterns keep working.
In: primitives, function types (params/returns/multi-return/varargs),
optionals (T?), unions (A | B), type aliases including structural tables,
type assertions, structs, tagged enums.
Also in (post-v1):
- Generics (
typecheck/generics.go) — generic aliases and structs (type Box<T>,struct Pair<A, B>), instantiated on application, plus best-effort call-site inference for generic functions. Inside a generic body a type variable is opaque but gradual, so parametric code never produces spurious errors. - Refinements / narrowing (
typecheck/refine.go) — type guards (type(x) == "T"), nil guards, truthiness,not/and/orpropagation, elseif negation accumulation,assert(cond)narrowing for the rest of the block, and short-circuit RHS narrowing (s ~= nil and #s;x or defaultalso dropsnil).- Early-exit narrowing: a leading prefix of always-terminating if-clauses
(return/break/continue/throw/
error()) persists its negations past theend.gotois deliberately not treated as a terminator. - Only simple identifiers refine — field paths (
x.y) are not tracked. - Narrowing shadows are marked in the env (
defineRefined); assignments check against the declared type (lookupDeclared) and widen active shadows (widenRefined), so a stale refinement can never vouch for a dead value.
- Early-exit narrowing: a leading prefix of always-terminating if-clauses
(return/break/continue/throw/
A leading --!strict, --!nonstrict, or --!nocheck on line 1 sets the file's
strictness. --!strict turns implicit-any parameters into errors; --!nocheck
skips the type pass entirely (but not constcheck).
Intersections (A & B), string-singleton types ("foo" | "bar"), cross-module
type checking (require() returns any), and recursive aliases (the parser
accepts them; the resolver does not). These are named explicitly in error
messages so users hit a clear wall rather than a silent miscompile.
Also out at the language level: GC metamethods (__gc, and __close
enforcement — the attribute is parse- and const-checked only); full debug
semantics (debugx ships real traceback/getinfo but hook stubs, not
VM hooks); finally on try/catch and type-filtered catch clauses (defer
already covers unconditional cleanup, and a handler can re-throw after
inspecting the value — the catch binding is always any because the checker
cannot narrow what a throw produces).
Stack-based, in internal/vm/. Closures, metatables, coroutines (goroutines +
channels), and pcall/error unwinding. Performance-sensitive pieces: a
framePool capped at 256, pushNils, and bsearch-based open-upvalue lookup.
The FFI rule. Only nil, bool, int64, float64, string, *Table,
*Closure and *GoFunc are runtime-tracked. Cast raw Go int, FileMode,
rune, etc. to int64 at the boundary. Argument helpers live in
vm/stdlib_args.go (NumArg/IntArg/FloatArg/StringArg, TableArg,
ClosureArg, CoroutineArg, AnyArg, NilOrTableArg, TableOrStringArg,
OptString, OptInt) — prefer these over hand-rolled panics.
__tostring is honoured by tostring, print, io.write,
error(value), and the REPL's value printer, all routed through
vm.ToStringMM(v, val) in vm/value.go. Numbers, strings, bools and nil skip
the metamethod lookup; tables and userdata route through __tostring when
present and panic if it returns a non-string.
Multi-return spread into a call (e.g. print("x", string.gsub(...))) works
via the MarkArgs opcode. When compileCall / compileMethodCall sees a
multi-value last argument it emits MarkArgs to record the stack height; the
matching Call is emitted with nargs=-1 and doCall recovers the args base
from v.callMarks. Static, fixed-arity calls keep the fast path and pay no mark
overhead.
Arithmetic / comparison fast paths. Add, Sub, Mul, Lt and Le have
inline int+int and float+float paths in the dispatch loop that skip the
string-keyed arithMM/lessMM dispatch. Mixed types and metatable paths still
go through the full *MM helpers.
Lua patterns. string.match, gmatch, gsub and find implement the full
Lua pattern surface — classes (. %a %d %s %w and complements), [set] with
ranges, ^ $ anchors, * + - ? quantifiers, ( ) captures including empty
position captures, %1..%9 backrefs in both patterns and replacements, %b()
balanced, and %f[set] frontier. string.find engages the engine only when
magic characters are present, keeping a plain-substring fast path otherwise. The
engine is vm/patterns.go.
vm/traceback.go is the whole of the error-reporting surface. Two facts drive
its shape.
Frames survive the panic. A raised error unwinds the Go stack, but
v.frames is VM state that nothing touches on the way out — execCatching
re-panics without unwinding when it has no handler for the error. So at the
moment an error is finally caught, the Lua call stack that produced it is still
intact and can be read. Exactly four places catch one and then destroy those
frames:
| boundary | catches for |
|---|---|
safeCall |
pcall / xpcall / VM.SafeCall |
dispatchToHandler |
a try region |
Coroutine.goroutineBody |
a coroutine dying, reported through resume |
recoverToError |
the error reaching the host uncaught |
Each calls v.errorValue(r) (or v.toRuntimeError(r) at the top) as the first
statement in its recover, before any unwinding. Everywhere else the panic is
re-panicked untouched, so the deepest boundary is always the one that records
the position — which is the raise site, not the handler.
The panic's type says who owns the position. LuaError (and a bare Go
error) means the VM raised — "attempt to index a nil value" — and gets the
<source>:<line>: prefix stamped on. luaError means a script raised via
error/assert/throw, where positioning is the raiser's business: error
already applied it at the requested level, and throw is deliberately verbatim.
That split is what keeps a value from being prefixed twice.
An uncaught error reaches the host as a *RuntimeError carrying the raised
value, the positioned message, and the captured stack. Its Error() renders
message plus traceback, which is what the CLI prints; Message() is the bare
message. A stack that is only the main chunk renders without a traceback — it
would repeat what the message's own prefix already said.
Chunk names are stamped at load, not compile. InstructionSet.SetSource
walks a chunk and its nested protos, and is called by whoever read the chunk:
repl.RunFile, require, loadfile, load (honouring its chunkname argument
and Lua's =/@ sigils), and luascript profile. It deliberately does not run
in the generator and is not serialized, because the bytecode cache is keyed on
content: one cached chunk may legitimately be loaded from two paths. A chunk
nobody stamped reports as script.
Captures are bounded: Traceback keeps the innermost 10 and outermost 11
frames and collapses the rest into a ... (skipping N levels) marker, so a
runaway recursion against a 200,000-frame ceiling does not render — or even
allocate — one line per frame.
Function names in a traceback come from the proto name, which the generator now
takes from the binding when there is one: local function f, function a.b:c,
local f = function() end and M.run = function() end all name their literal.
A genuinely anonymous literal falls back to anon@<line>, which at least
locates its definition. debug.traceback and debug.getinfo render through the
same vm.Traceback / vm.FormatTraceback pair, so the module and an uncaught
error cannot drift apart.
Known gap: xpcall runs its message handler after unwinding, so a handler
that calls debug.traceback sees its own stack rather than the failed call's.
Lua runs the handler before unwinding; matching that means calling back into Lua
from inside the recover, which is a larger change than this bought.
try is a real protected region in the enclosing frame — deliberately not
a pcall-plus-closure lowering. That choice is load-bearing: it is what makes
return, break and continue inside a try act on the enclosing function or
loop, and what keeps the body's locals ordinary frame slots.
- Opcodes.
Try(A = catch IP) pushes a handler;EndTry(A = count) pops N;Throwpops a value and raises it.throwbeing an opcode rather than a call toerrormeans shadowingerrorcannot re-point it. (erroritself propagates its argument verbatim, so the two are otherwise identical.) - Handlers live on the
CallFrame(f.handlers), not on the VM. Two consequences fall out: areturnout of atryneeds no bookkeeping, since unwinding the frame discards the handlers; and a coroutine's handlers travel with its frames across the yield/resume state swap. - The recover point is
execCatching, entered fromexeconly when the proto'sHasTry()is set — so every other call pays one predictable branch and nothing more.HasTryis derived byscanProto, the same one-time cached scan that resolvesNumLocals, which is why a proto loaded from the bytecode cache gets it for free with no extra serialized field. dispatchToHandlerperforms the same restorationsafeCalldoes — abandoned frames' defers, thencloseUpvaluesAbove, then truncate frames/stack/callMarks — but stops at the try's frame. An unhandled error is re-panicked, so it keeps unwinding to an outer try, apcall, or the host.- Every non-raising exit emits
EndTry: the body's fall-through, and anybreak/continuethat escapes one (Generator.exitTryDepth, stamped ontoloopFrame.tryDepthbypushLoop). Every loop form must push throughpushLoop— otherwise abreakinside atryleaves a stale handler and a later error lands in a catch that has already been jumped out of. gotoacross atryboundary (either direction) is a compile error, not a miscompile — seecheckGotoTryDepth.
Lua code runs only on the VM goroutine, one call at a time.
This is not a policy choice that can be relaxed. The VM has no locks: Globals
is a plain map, open upvalues point into &vm.Stack, and the GetGlobal inline
cache writes back into the shared *bytecode.Instruction. Running Lua on two
goroutines is a torn read, not a speedup.
The queue module (internal/native/stdlib/queue/) is built around that
invariant by splitting scheduling from execution:
dispatcher.gois a thread-safe scheduler — two heaps (ready, ordered by priority then a monotonicseqfor FIFO; and delayed, ordered byReadyAt). It runs nothing. Submitting is safe from any goroutine.queue.go::pumpdrains the dispatcher on the VM goroutine and is the only place a job is invoked, always viavm.SafeCall(neverCallValue) so a failing job cannot leave the shared VM dirty.q:run()blocks and drains;q:poll()takes only what is due.- Goroutines exist only in
queue.afterandqueue.tick, which push into a channel and never touch the VM.
Do not "improve" this with a worker pool that calls Lua — that was the
original bug. Two corollaries follow: timeout_ms is a deadline on starting
(expired jobs are shed unrun; a running Lua call cannot be preempted), and
Channel never closes its data channel — Close closes a separate done
channel, so a send racing a close reports Closed instead of panicking.
httpserver follows the same shape: :listen blocks on the VM goroutine and
dispatches handlers through a buffered jobCh (cap 64), with an 8 MiB body cap
(→413), :stop(), and a clean ErrServerClosed exit.
Single source of truth: cmd/luascript/natives.go::nativeRegistrars is the
only list of bundled native modules. Both code paths walk it:
cmd/luascript/main.go→repl.AddPostInit(CLI path; re-applied on REPL:reset).cmd/luascript/build.go::runBundled→registerAllNatives(bundled binary).
Each native package exports a one-line RegisterX(v *vm.VM) that calls
vm.RegisterPreload(v, name, loader) (helper in vm/preload.go). Adding a
native module is one line in nativeRegistrars. Native method names must
avoid Lua reserved words — which is why regexp exposes :capture, not
:match.
Native modules arrive via package.preload on the host side, not the path
search.
Two modules ship a real backend and a stub behind build constraints, so
require resolves everywhere and scripts can branch at runtime:
ui(Fyne desktop GUI) is off by default because Fyne pulls in OpenGL via cgo. The default build compilesui_stub.go:require("ui")resolves but errors on first widget construction.-tags luascript_uiselectsui_fyne.goand needs CGO plus a C toolchain and OpenGL headers.plugin(load Go packages at run time) needs cgo and a platform where Go supports plugins.backend_native.gois//go:build (linux || darwin || freebsd) && cgo;backend_stub.gocovers everything else. Windows can never run plugins —plugin.supportedisfalsethere andgenerate/openraise.
There is no postgres build tag: lib/pq is a plain blank import in
internal/native/stdlib/db/db.go. The intended pattern for further drivers is a
driver_<name>.go file with a //go:build directive.
A script declares packages and functions; the module renders a package main
re-exporting them as package-level vars, compiles it with
go build -buildmode=plugin, opens the .so, and dispatches through
reflect.
- Lua→Go conversion is driven by the target parameter type (
fn.Type().In(i)), so one Lua integer satisfies a Goint,float64ortime.Duration. Go→Lua obeys the FFI rule (ints widen toint64, floats tofloat64,[]byte→ string, slices/maps →*Table). A returnederrorkeeps its position and becomesnilor its message string. - Values with no Lua counterpart (structs, pointers, interfaces — e.g.
*sql.DB) are wrapped as a GoValue using the same*Table+ private-key + shared-metatable pattern asndarray(key"\x00govalue"), whose__indexresolves exported methods and fields by reflection, sodb:Query(...)works. Passing a GoValue back into Go unwraps it. - A generated
var X = pkg.Xcomes back fromplugin.Lookupas a pointer to the var, soloadedPlugin.lookupdereferences pointer-to-func before calling. Non-func pointers are left alone, keeping pointer-receiver methods reachable. - Artifacts live under
os.UserCacheDir()/luascript/plugins/<name>-<sha256(source)[:16]>[-race]/, one directory per plugin (twofunc mains cannot share a package).LUASCRIPT_PLUGIN_DIRrelocates it. An unchanged spec is a cache hit; only specs with non-stdlib imports rungo mod tidy. - A plugin must match the host's build config,
-raceincluded.raceEnabled(build-tagged,race.go/race_enabled.go) forwards-raceto the plugin build and appends-raceto the cache directory. Without the suffix a race-enabled host would get a cache hit on a non-race.soandplugin.Openwould reject it. Same class of constraint as the Go version pinned ingoMod. - The plugin imports only the requested packages — never
internal/vm— so "plugin was built with a different version of package X" is limited to a toolchain or-racemismatch. A plugin importing a package the host also links (e.g.lib/pq) can still hit it if versions drift. generateruns the Go compiler and loads native code: arbitrary code execution by design. Specs are validated (identifiers, import paths) to turn typos into Lua errors, not as a security boundary.
ndarray, dataframe, csv, stats, linalg, clustering,
classification, ml (package luaml) and plot. ndarray values are
*Table wrappers sharing one metatable, carrying the backing *ndarray under a
private instance key ("\x00ndarray"); 0-D results (vector dot, full
reductions) come back as bare Lua numbers rather than wrapped values. plot is
dependency-free SVG charting and uses the same wrapper pattern.
Note that internal/native/constraints is a Go generics type-constraint helper
package, not a Lua module.
require resolves against package.path, in this order:
- The directory of the script being run (added automatically).
- cwd-relative entries (
./?.lsc,./src/?.lsc, …). $LUASCRIPT_LIB, only if set (read invm/loader.go).
bytecode/serialize.go provides SerializeChunk / DeserializeChunk (magic
LSCB, SerialVersion). It round-trips the main InstructionSet plus nested
Protos; Params are reconstructed from the typed fields on load (the inverse
of encodeParams), so the disassembler works on deserialized chunks. The header
embeds InstructionCount, so renumbering opcodes invalidates old chunks even
without a SerialVersion bump.
internal/compiler/bccache/ is the on-disk compile cache, used by RunFile,
require and loadfile — but not by the REPL, load() strings, -dis, or
bundled executables. Entries live under
os.UserCacheDir()/luascript/bytecode/<sha256>.lscb, keyed by source +
interpreter version + serial format + opcode count. They are written atomically
and only after a fully successful compile, so type errors are never cached, and
any decode failure falls back silently to a fresh compile.
Env knobs: LUASCRIPT_NOCACHE=1 disables it; LUASCRIPT_CACHE_DIR relocates it
(the tests use this).
luascript build appends source + version + magic("LUASCRIPT01") to a copy of
the interpreter binary. On startup cmd/luascript/main.go::readEmbeddedPayload
inspects its own tail before flag parsing; when a trailer is present it runs
the embedded script in parser.NormalMode and re-registers all natives via
registerAllNatives.
Source-payload only — bytecode serialization, cross-compilation and compression
are v1 non-goals. The bundled binary matches the host platform, bundled scripts
do not see os.Args, and antivirus heuristics occasionally flag
self-appending executables (the same trade-off PyInstaller and Bun --compile
carry; code-signing resolves it).
internal/docs/ is the single source of truth for the stdlib reference: one
curated Entry per global, library function, module member and object method,
grouped into Topic man pages (data_core.go, data_library.go,
data_modules.go, data_datascience.go, data_objects.go) plus a renderer
(render.go). It is data only — it imports neither the VM nor the compiler,
so every consumer shares it:
luascript doc/man(cmd/luascript/doc.go) — pages, entries, index, apropos (-k),-all.- The REPL's
doc <topic>command (repl.docCommand/printDoc). internal/lsp/server/builtins.goderives all completion and hover from it, keeping no table of its own — so a new entry here reaches the editor too.
Drift is checked, not assumed. luascript doc -audit (and
TestDocsMatchRuntime, the same code path via auditDocs) loads every native
module and auto-global into a real VM and diffs the live member set against the
registry, reporting both undocumented and stale names. Topics point at their
runtime surface with RuntimeModule / RuntimeGlobal; math and io set both
because they exist twice (a small auto-global and a larger native module) and
are documented as one merged page (Requireable: true). Object topics are
exempt — their methods live on constructed values that reflection cannot reach.
| Package | What it is |
|---|---|
internal/repl/ |
Readline-driven REPL, history, continuation prompts; type errors get a type-error: prefix |
internal/formatter/ |
luascript fmt; trivia-preserving via trivia.go |
internal/compiler/analyze/ |
Pass-registry static analyzer; read-only, re-parses source, has its own walker |
internal/lsp/ |
Language server (luascript lsp) — protocol, jsonrpc2, uri, server/ |
internal/pkgmanager/ |
luascript pkg — manifest, lockfile, fetch |
internal/compiler/debug/ |
pprof Start/Stop wrappers behind luascript profile |
internal/gctune/ |
GC knobs behind -gc-percent / -mem-limit |
internal/bonsai/ |
ASCII bonsai side mode (cbonsai/gobonsai fork), unrelated to the runtime |
internal/lsp/protocol, internal/lsp/jsonrpc2 and internal/lsp/uri are
adapted from gopls and intentionally keep the complete protocol binding,
including the client-side half the server does not call. Reachability tools
report much of it as unused; that is expected and is not dead code to prune —
trimming it would fragment the binding and make re-syncing upstream harder.
internal/bonsai/ uses tcell v3 (<-sc.EventQ() / Get(x,y)) and a per-Run
RNG so -seed is deterministic. Its alt-screen path cannot be verified
programmatically; use -bonsai-print for non-interactive runs.
.
├── cmd/luascript/ CLI entrypoint + `luascript build` bundler + natives.go
├── internal/
│ ├── compiler/ lexer, token, parser, ast, constcheck, typecheck,
│ │ optimize, bytecode, bccache, analyze, debug, compiler.go
│ ├── vm/ stack VM, closures, metatables, coroutines, stdlib
│ ├── native/
│ │ ├── stdlib/ db, os, http, json, std, queue, log, io, …
│ │ └── datascience/ ndarray, dataframe, stats, linalg, ml, plot, …
│ ├── plugin/ run-time Go package loading (cgo, non-Windows)
│ ├── docs/ stdlib reference data + man-page renderer
│ ├── lsp/ language server
│ ├── formatter/ `luascript fmt`
│ ├── repl/ interactive REPL
│ ├── pkgmanager/ manifest / lockfile / fetch
│ ├── bonsai/ ASCII bonsai side mode
│ ├── gctune/ GC tuning helpers
│ └── version/ version string
├── client/ VS Code extension (TypeScript)
├── examples/ runnable .lsc programs that double as tutorials
├── scripts/ build-pgo.sh, benchmark.rb
└── assets/ logo and static assets
Note: the repository directory on disk is still named
sakura-lang(pre-rename), and a few Go comments carry sed-rename artifacts (e.g..lsc profile,-tags.lsc_no_postgresinnative/stdlib/db/db.go, thescripts/build-pgo.shheader). The module path, package names, env vars and build tags are the authoritative current names.
Tests live next to the code (*_test.go). make check — gofmt -l . +
go vet ./... + go test ./... — is the pre-commit gate.
The bytecode tests assert exact opcode sequences for representative source
snippets, so they catch codegen drift early. The type checker has its own suite
in typecheck/checker_test.go.
Things automated checks cannot reach, and which therefore need a manual smoke-test before you rely on them:
-
Interactive paths: the REPL,
httpserver, and anything stdin-driven. -
Alt-screen modes: bonsai (use
-bonsai-printinstead). -
The cgo-gated
uibuild. -
The
pluginmodule's real backend, which cannot be built or run on Windows at all; cross-compiling withGOOS=linux CGO_ENABLED=1also fails for want of a Linux C toolchain. Verify it in WSL, which runs the actualgo build -buildmode=plugin→plugin.Openchain:wsl -d Ubuntu-22.04 -e bash -lc 'cd /mnt/d/.../Luascript && CGO_ENABLED=1 go test ./internal/plugin/' wsl -d Ubuntu-22.04 -e bash -lc 'cd /mnt/d/.../Luascript && CGO_ENABLED=1 go run ./cmd/luascript examples/53_plugin.lsc'
go test -race ./internal/plugin/in WSL is worth running too, given the-racecache-key constraint described above.
Because of the -race/build-config coupling and the Windows gap, everything in
internal/plugin/ except backend_native.go is platform-independent and unit
-tested on Windows; convert.go in particular imports no plugin package so it
compiles and tests everywhere.