Geblang Internals
This chapter describes how Geblang is implemented, written for readers who want a structural walkthrough of a scripting language built in Go. It covers the full pipeline from source text to execution, and the design choices made at each layer.
All source paths below are relative to the repository root.
The Pipeline
A Geblang program goes through these stages before any code executes:
source text
|
v
Lexer (internal/lexer) -- produces a stream of tokens
|
v
Parser (internal/parser) -- produces an AST
|
v
Semantic Analyzer (internal/semantic) -- validates declarations
|
v
two possible execution paths:
[Bytecode Compiler] -- preferred path
(internal/bytecode/compiler)
|
v
[VM]
(internal/bytecode/vm)
[Evaluator] -- compatibility / fallback path
(internal/evaluator)
The bytecode path is tried first. If compilation succeeds the VM runs the
resulting chunk. If compilation fails and strict-VM mode is not set, the
evaluator runs the AST directly. The --trace-exec flag prints which engine
ran a given script.
Tokens: internal/token
token.Token is the atom the lexer produces and the parser consumes. Each token
has a Type (an integer constant), a Literal string, a Raw string, and
source Line/Column fields.
Token types are simple integer constants declared in token/token.go: Ident,
Int, Decimal, Float, String, Assign, Plus, Eq, And, Or,
LParen, LBracket, LBrace, Dot, Arrow, NullCoalesce, Range,
EOF, and so on. The full list is about 80 types covering every syntactic atom
the language uses.
Lexer: internal/lexer
lexer.Lexer turns a source string into a sequence of tokens. Its state is
minimal:
type Lexer struct {
input string
position int // current byte offset
readPosition int // next byte offset
ch rune // current character
line int
column int
pendingDocs []string // accumulated doc comments
}
NextToken() is the only interface the parser calls. It dispatches on the
current character using a large switch, emitting one token per call. Multi-byte
sequences like ==, !=, <=, **=, .., ..<, ?., and ?? are
handled by peeking one character ahead.
Identifiers and keywords share the same token type initially; the lexer checks a
keyword table before returning the token, upgrading Ident to the keyword type
where appropriate. String literals handle escape sequences and Unicode in the
lexer. Numeric literals support decimal underscores (1_000_000), hex
(0xFF), octal (0o77), and binary (0b1010) prefixes.
Doc comments (/// or /** ... */) are accumulated in pendingDocs and
attached to the next non-comment token so the parser can associate them with the
declaration they annotate.
AST: internal/ast
The AST defines the tree of nodes the parser builds.
Core interfaces
type Node interface {
TokenLiteral() string
String() string
}
type Statement interface {
Node
statementNode()
}
type Expression interface {
Node
expressionNode()
}
ast.Program holds []Statement and is the root returned by every parse call.
Statements and expressions
There are around 40 statement types (LetStatement, FunctionStatement,
ClassStatement, ReturnStatement, ForStatement, IfStatement,
TryCatchStatement, DeferStatement, ImportStatement, etc.) and a similar
number of expression types (Identifier, IntegerLiteral, StringLiteral,
CallExpression, SelectorExpression, IndexExpression, InfixExpression,
PrefixExpression, FunctionLiteral, ListLiteral, DictLiteral, and more).
TypeRef
TypeRef is a recursive type annotation node used everywhere a type appears:
type TypeRef struct {
Token token.Token
Name string // base name, e.g. "list", "string", "T"
Nullable bool // ?string
Arguments []*TypeRef // generic arguments: list<string> -> Arguments[0].Name="string"
ListAlias bool // T[] shorthand -- Name holds the element type
Left *TypeRef // left side of a union/intersection operator
Operator string // "|" or "&"
Right *TypeRef // right side
}
list<int> produces {Name:"list", Arguments:[{Name:"int"}]}. int[]
produces {Name:"int", ListAlias:true}. string|int produces
{Operator:"|", Left:{Name:"string"}, Right:{Name:"int"}}.
Parser: internal/parser
The parser is a hand-written Pratt (top-down operator precedence) parser. Its
main entry point is parser.New(lexer).ParseProgram(), which returns
*ast.Program.
Pratt parsing
Each token type optionally has two parse functions registered against it:
- a prefix function, called when the token appears at the start of an
expression (literal values, prefix operators,
(grouped expr)) - an infix function, called when the token appears between two expressions
(binary operators,
[index],(call),.member)
parseExpression(precedence) calls the prefix function for the current token,
then repeatedly calls infix functions while the next token has higher precedence
than precedence. This naturally handles operator precedence and associativity
without a grammar table.
Precedence levels
lowest
assign = += -= ...
ternary ?
nullCoalesce ??
logicalOr ||
logicalAnd &&
bitOr |
bitXor ^
bitAnd &
equality == != is instanceof
compare < <= > >= .. ..< as
shift << >>
sum + -
product * / // %
power **
prefix ! - ~ ++ --
postfix ++ --
call . ?. ( [
Type reference parsing
parseTypeRefFromCurrent() handles the full type syntax: nullable prefix ?,
generic arguments <T, U>, union | and intersection & operators, and the
list shorthand []. It converts int[] into a TypeRef with
ListAlias=true and Name="int".
Statement parsing
Top-level statements are parsed with parseStatement(). When a keyword is seen
(let, func, class, if, for, return, etc.) the corresponding
dedicated parse function is called. Otherwise the statement is treated as an
expression statement. The parser requires semicolons to terminate most
statements, following C-style syntax.
Semantic Analyzer: internal/semantic
semantic.Analyzer performs a lightweight pre-execution pass over the AST.
It does not do full type inference; its job is to catch structural errors that
neither the parser nor runtime is well-placed to handle.
type Analyzer struct {
diagnostics []Diagnostic
scopes []map[string]typeInfo
functions map[string][]methodInfo
classes map[string]classInfo
interfaces map[string]interfaceInfo
aliases map[string]typeInfo
}
Analyze(program) does four things in order:
collectTypeDeclarations: walks all statements to register functions, classes and interfaces so that forward references work.validateTopLevelOverloads: checks that overloaded functions at the top level have distinct signatures.validateClassOverloads: same check for methods and constructors.validateInterfaceImplementations: verifies that classes markedimplements Fooactually provide every method the interface declares.
Errors are collected as []Diagnostic rather than halting immediately, so the
caller receives all problems at once.
Runtime Values: internal/runtime
Every value a Geblang program can hold implements the Value interface:
type Value interface {
TypeName() string
Inspect() string
}
TypeName() returns the runtime type string ("int", "string", "list",
"func", etc.). Inspect() returns a human-readable representation.
Primitive types
| Go type | Geblang type | Notes |
|---|---|---|
Null{} |
null |
singleton |
Bool{Value bool} |
bool |
|
SmallInt{Value int64} |
int |
int64 fast path used by the VM |
Int{Value *big.Int} |
int |
arbitrary precision; overflow / large literals |
Decimal{Value *big.Rat} |
decimal |
exact rational arithmetic |
Float{Value float64} |
float |
IEEE 754 double |
String{Value string} |
string |
UTF-8 |
Bytes{Value []byte} |
bytes |
raw byte slice |
SmallInt is the int64-backed representation used wherever the value
fits in a signed 64-bit register; the bytecode VM hot path operates on
SmallInt without ever touching runtime.Value (see VMValue below).
Int is the arbitrary-precision fallback for overflow and for literals
that do not fit in int64.
Decimal uses math/big.Rat so that 0.1 + 0.2 produces 0.3
exactly. Literal 3.14 parses as Decimal, not Float; 3.14f is a
Float.
Collection types
| Go type | Geblang type | Notes |
|---|---|---|
List{Elements []Value} |
list |
ordered, mutable |
Dict{Entries map[string]DictEntry} |
dict |
string-keyed, mutable |
Set{Elements map[string]SetEntry} |
set |
unordered, mutable |
Range{Start, End *big.Int, ...} |
range |
lazy integer sequence |
Dict entries are keyed internally by the string form of the key value, but the original key value is preserved alongside each entry so the runtime can return the original type.
Callable types
Function is the evaluator's callable value. It holds the AST parameter list,
body, captured environment, and optional Native hook for Go-implemented
functions. OverloadedFunction wraps a slice of Function values that share a
name; dispatch selects the overload whose parameter count and types match the
call arguments.
BytecodeFunction and BytecodeClosure are the VM equivalents. They store an
index into the chunk's Functions table and (for closures) a slice of captured
upvalues.
Object types
Instance represents a class instance. It holds a pointer to its class
descriptor and a map of field names to values.
Module holds an exported name-to-value map and is what import statements
bind.
Special runtime values
Generator wraps either a pre-computed value slice or a next callback
function, and provides Next()/(value, bool, error) iteration. Generator
coroutines in the VM communicate through a Go channel pair.
DateTimeInstant, DateTimeDuration, DateTimeZone, URLValue,
HTTPHeaders, HTTPCookie, TemplateValue, and TemplateEngine are typed
opaque wrappers for domain-specific native values. They carry type names like
"datetime.Instant" so the runtime can dispatch methods on them correctly.
Bytecode Format: internal/bytecode/bytecode.go
Chunk
A Chunk is the compiled form of one source file or module:
type Chunk struct {
SourceHash [32]byte // SHA-256 of the source bytes
Compiler string // version string, for cache invalidation
Constants []Value // literal pool
Instructions []Instruction // flat instruction array
Functions []FunctionInfo
Classes []ClassInfo
Interfaces []InterfaceInfo
Exports []ExportInfo
}
All functions, including the top-level script, share the same flat
Instructions array. Each FunctionInfo holds an Entry offset into that
array, so function calls are just an IP jump.
Instruction
type Instruction struct {
Op Op // opcode byte
Operands []int64 // variable-length operand list
Line int
Column int
}
Operands are 64-bit signed integers. Most opcodes take zero or one operand (a constant pool index, a local slot, a jump target). Multi-operand instructions are rare.
Opcodes
The Op type is a byte. There are around 110 opcodes. Representative
examples:
| Opcode | Action |
|---|---|
OpConstant |
push Constants[operand] onto the stack |
OpAdd / OpSub / OpMul / OpDiv |
pop two values, push result |
OpDefineGlobal / OpGetGlobal / OpSetGlobal |
named global access |
OpDefineLocal / OpGetLocal / OpSetLocal |
slot-indexed local access |
OpCall |
call top-of-stack function with N args |
OpMethodCall |
call named method on receiver |
OpNativeCall |
call a registered native function |
OpBuildList / OpBuildDict / OpBuildSet |
pop N values, push collection |
OpJump / OpJumpIfFalse |
unconditional / conditional branch |
OpReturn |
pop return value, restore frame |
OpPushExceptionHandler / OpPopExceptionHandler |
try/catch frame management |
OpThrow |
raise an error value |
OpYield |
suspend a generator, send a value to the caller |
OpAwait |
suspend an async function until a Task resolves |
OpTypeAssert |
runtime check that the top-of-stack value matches a type string |
OpMakeClosure |
wrap a function with captured upvalues |
OpSetTypeBindings |
bind generic type parameters for the current call frame |
OpImportModule |
load and cache a module by name |
OpConstructClass |
allocate an instance and call its constructor |
OpDefineClass |
register a class descriptor in the global table |
FunctionInfo
type FunctionInfo struct {
Name string
TypeParameters []string // generic parameter names
Entry int64 // offset into Instructions
ParamNames []string
ParamSlots []int64 // local slot index for each parameter
ParamTypes []string // type strings for runtime checks
ReturnType string
DefaultConstants []int64 // constant pool indices for default values
UpvalueCount int64
Variadic bool
Async bool
IsGenerator bool
Decorators []DecoratorMetadata
}
Serialization
bytecode.Encode(chunk) serializes a Chunk to bytes. The format starts with
the magic bytes "GEBBC", a 2-byte version number, the SHA-256 source hash, and
then length-prefixed sections for constants, instructions, functions, classes,
interfaces, and exports. bytecode.Decode(bytes) is the inverse.
The source hash lets the runtime skip recompilation: if the cached .gbc file's
hash matches the source file, the cached chunk is loaded directly.
Bytecode Compiler: internal/bytecode/compiler.go
bytecode.Compile(program, source, version) is the entry point. It creates a
Compiler, walks the AST, and returns a Chunk.
Compiler state
type Compiler struct {
chunk Chunk
loops []loopContext // break/continue target stack
globals map[string]int64 // name to constant pool index
globalTypes map[string]string // name to type string
scopes []map[string]binding // lexical scope stack
locals int64 // next local slot
funcs map[string][]int64 // name to overload function indices
classes map[string]int64 // name to class index
interfaces map[string]int64
enums map[string]int64
typeAliases map[string]*ast.TypeRef
inFunc int // nesting depth for functions
classStack []int64 // for `this` inside methods
finalizers []finalizerContext // defer/finally cleanup stack
expectedTypes []string // declared types of let bindings in scope
returnTypes []string // expected return types of enclosing functions
reflectFuncs map[string]DecoratorTarget // decorator metadata
...
}
Compilation pass
The compiler does a single forward pass over program.Statements. At the end it
patches all forward-reference jump targets by back-filling jump instruction
operands.
For each statement kind the compiler emits a sequence of instructions. A function declaration, for example:
- Appends a new
FunctionInfoentry tochunk.Functions. - Saves the current emit position and emits an
OpJumpplaceholder to skip over the function body. - Records the function entry point, then compiles the body.
- Emits
OpReturnat the end. - Patches the
OpJumpoperand to point past the function body. - Emits
OpDefineGlobal(orOpDefineLocal) to bind the function value in the current scope.
Local variable scopes are managed with a stack of maps (scopes). Each let
binding allocates the next locals slot, recorded in the innermost scope map.
Reads and writes emit OpGetLocal/OpSetLocal with the slot number. At scope
exit the slot count is not reclaimed immediately (the VM allocates a fixed local
array per call frame).
Closures are compiled as functions with a non-zero UpvalueCount. The compiler
identifies captured variables and emits OpMakeClosure with a list of upvalue
indices.
Type strings (for OpTypeAssert, ParamTypes, ReturnType) are produced by
bytecodeTypeNameForParam, which flattens a TypeRef tree into a string like
"list<int>", "?string", or "dict<string,list<float>>". Nested generic
types are serialized with brackets, and the VM parses them back with
bracket-depth-aware helpers.
VM: internal/bytecode/vm.go
VM state
type VM struct {
chunk Chunk
stdout io.Writer
stack []runtime.VMValue // operand stack (tagged union)
globals []runtime.VMValue // indexed by global slot
locals []runtime.VMValue // current frame's locals (alias into frames)
frames []callFrame // call stack
defers [][]deferredAction
exceptionHandlers []exceptionHandler
pendingThrow *runtime.Error
moduleLoader ModuleLoader
statefulNative StatefulNativeCaller
classIndex map[string]int // class name to Classes index
natives *native.Registry
syncMode bool // run async funcs synchronously
...
}
The stack / locals / globals slices hold runtime.VMValue
(internal/runtime/vmvalue.go), a 32-byte tagged union with an inline
int64 payload and an interface-typed boxed fallback:
type VMValue struct {
Kind VMKind // 1-byte tag (Null, Bool, SmallInt, Float, Boxed, ...)
I64 int64 // SmallInt payload / bool 0|1 / Float64bits
Boxed runtime.Value // catch-all for List, Dict, Class, Instance, Int, Decimal, ...
}
This representation removes the two runtime.SmallInt heap
allocations per integer arithmetic step that a runtime.Value
interface previously imposed. The fast paths for OpAddInt,
OpSubInt, OpLessInt, the fused jump-compare opcodes, and the
load-op-store opcodes all operate on the Kind == VMKindSmallInt
case inline. Any non-primitive value takes the VMKindBoxed
fallback and routes through the same runtime.Value interface
used by the evaluator, preserving identity through ToValue() /
VMValueFromValue() at the boundary.
Call frame
type callFrame struct {
returnIP int
locals []runtime.Value
typeBindings map[string]string // generic type parameter bindings
generator chan vmGeneratorItem
functionName string
callLine int
...
}
Each function call pushes a new callFrame. returnIP is the instruction index
to resume after the call returns. typeBindings holds generic type parameter
names resolved to concrete type strings for the duration of the call, populated
by OpSetTypeBindings.
Run loop
vm.Run() is a simple fetch-decode-execute loop:
ip := 0
for ip < len(vm.chunk.Instructions) {
instr := vm.chunk.Instructions[ip]
ip++
switch instr.Op {
case OpConstant:
vm.push(vm.chunk.Constants[instr.Operands[0]])
case OpAdd:
right := vm.pop()
left := vm.pop()
vm.push(vm.add(left, right))
// ... ~165 cases
}
}
The stack grows upward; push appends to the slice and pop removes from the
end. All arithmetic, comparisons, collections, control flow, function calls, and
error handling are implemented as opcode handlers in this loop.
A handful of opcodes operate on VMValue directly without round-tripping
through runtime.Value:
- Integer fast paths:
OpAddInt,OpSubInt,OpMulInt,OpModInt,OpLessInt,OpGreaterInt,OpEqualInt,OpIncLocalInt,OpDecLocalInt. Each checksKind == VMKindSmallInton its operands and performs the arithmetic on the inlineI64field. Overflow falls back toruntime.Int(big.Int) promotion. - Fused compare-and-branch:
OpJumpIfNotLessInt,OpJumpIfNotEqualInt, etc. A single opcode pops two SmallInts and jumps when the underlying boolean condition is false, removing the intermediate Bool push/pop. - Fused load-op-store:
OpAddLocalIntLocal,OpAddGlobalIntConst, etc. Cover thea = a + b/a = a + Npattern with a single instruction.
The compiler emits these specialised opcodes whenever both operands
are statically typed as int; otherwise the generic dispatch path
runs and routes through runtime.Value.
Function calls
OpCall pops N arguments and the callee value, then dispatches:
- If the callee is a
runtime.Function(evaluator-style), it calls back into the evaluator viaStatefulNativeCaller. - If the callee is a
FunctionInfoindex, it pushes a newcallFramewith the function's locals pre-allocated, setsiptoEntry, and continues the loop. - For native functions,
startFunctioncalls the registered Go function directly. - For closures, upvalues are injected into the new frame's locals before entering the body.
Type checking at call sites is done in startFunction using matchValueToTypeStr,
which walks the string-encoded type recursively. Generic type parameters in
typeBindings are skipped during element-level checks.
Locals snapshot
On entering a callee, the VM snapshots the current frame's locals so the caller and callee can share the same backing storage without one clobbering the other:
frame.locals = vm.snapshotLocals() // internal/bytecode/vm.go
The snapshot is a per-call copy() of the parent frame's locals slice
into a fresh buffer (reused from vm.localsFree when available). For
deep recursion this copy is measurable in CPU profiles; runtime.duffcopy
typically shows up at single-digit percent on BenchmarkRecursiveFib,
and is one of the larger remaining costs on recursion-heavy workloads.
Generators
A generator function runs in a separate goroutine. When the VM compiles a
generator call it creates a Go channel pair. The generator goroutine runs the
function body, sending each yielded value on the channel. The outer VM receives
values through OpIterNext, which reads from the channel, and signals
completion through OpIterClose.
Exception handling
OpPushExceptionHandler pushes an exceptionHandler onto a stack, recording
the instruction offset of the handler code. When an error is thrown (either by
OpThrow or a runtime operation), the VM unwinds to the nearest handler and
jumps to its offset. OpPopExceptionHandler removes the handler when the
protected block exits normally. Uncaught throws propagate as a Go error from
Run().
Deferred calls
defer statements push deferredAction values onto a per-scope slice. When a
function returns or a try block exits, the VM pops and executes all deferred
actions in reverse order.
Module loading
OpImportModule calls ModuleLoader.LoadModule(canonical, alias). The loader
compiles (or loads from cache) the target module's chunk, runs it in a fresh VM,
and returns the exported runtime.Module. Modules are cached after first load.
Evaluator: internal/evaluator
The evaluator is a tree-walking interpreter that executes the AST directly without compilation. It is the older and more complete execution path; new language features sometimes appear in the evaluator before the compiler and VM support them.
The main types are Evaluator (holds stdlib registrations, import caches, and
the evaluator configuration) and Session (wraps an Evaluator and an
Environment for one REPL session or script run).
evalExpression(expr, env) and evalStatements(stmts, env) are the core
recursive functions. They return a runtime.Value and an error.
Control-flow signals (return, break, continue, throw, exit) are communicated
as a signal struct rather than a Go error, avoiding the need to unwrap
errors at each level:
type signal struct {
kind string // "return", "break", "continue", "throw"
value runtime.Value // return or thrown value
thrown runtime.Value
exited bool
exitCode int
}
The evaluator handles all the same language features as the VM but through direct Go function calls rather than opcode dispatch.
Builtin modules
The evaluator implements stateful standard library modules (HTTP client, database,
filesystem, Redis, templates, etc.) as Go structs with method maps. Each module
is registered in e.builtins under its canonical name and provides a
map[string]builtinFunction of callable entries. Module functions receive
[]runtime.Value arguments and return (runtime.Value, error).
Native Registry: internal/native
native.Registry holds pure, stateless functions shared between the evaluator
and the VM. These are functions whose behavior depends only on their arguments
and produces no side effects beyond their return value: math, string
manipulation, encoding, parsing, cryptography, and similar utilities.
Registering a native function associates a module name, a function name, and a
Go func([]runtime.Value) (runtime.Value, error) implementation. The registry
is initialized once and passed to both the evaluator and the VM.
The evaluator calls registry.Call(module, name, args) directly. The VM emits
OpNativeCall with the module and name embedded as constants, and resolves the
call through the same registry at runtime.
Module System: internal/modules
modules.Resolver locates .gb source files for import statements.
type Resolver struct {
ModulePaths []string // user-specified search paths
StdlibPaths []string // stdlib installation paths
DisableStdlib bool
Manifests map[string]*Manifest
}
Resolution order:
- Check if the import name matches a builtin native module (registered in the evaluator or native registry).
- Look for
<name>.gbrelative to the importing file's directory. - Search each
ModulePathsentry. - Search each
StdlibPathsentry.
A Manifest (geblang.yaml) can declare a module name, version, source
entrypoint, and additional module paths. The resolver loads manifests to support
multi-file packages.
Source Stdlib: stdlib/
Not all standard library code is written in Go. Several modules are written in
Geblang itself and distributed as source files in the stdlib/ directory:
| File / directory | Module |
|---|---|
stdlib/option.gb |
option (Option<T> type) |
stdlib/result.gb |
result (Result<T, E> type) |
stdlib/pathlib.gb |
pathlib (path manipulation) |
stdlib/mailer.gb |
mailer (high-level mailer) |
stdlib/config.gb |
config (typed configuration loading) |
stdlib/redis.gb |
redis (Redis client wrapper) |
stdlib/functools.gb |
functools (pipe / compose / partial / memoize) |
stdlib/http/ |
HTTP server utilities |
stdlib/web/ |
Web framework helpers |
stdlib/cli/ |
CLI argument parsing, cli.color ANSI styling |
stdlib/async/ |
Async utilities, async.rate throttle/debounce |
stdlib/testing/ |
Test runner and assertions |
stdlib/schema/ |
Schema validation |
These modules are installed alongside the geblang binary and are found via
StdlibPaths. They are compiled and cached like any other user module.
CLI: cmd/geblang
The geblang binary entry point is cmd/geblang/main.go. It parses command-
line arguments and delegates to one of several execution modes:
- Script mode:
geblang script.gbreads and parses the file, runs the semantic analyzer, then tries the bytecode path and falls back to the evaluator. - Module mode:
geblang -m moduleNamegenerates a thin wrapper script that imports the named module and calls itsmain()function. - REPL mode:
geblangwith no arguments starts an interactive session. The REPL (repl.go) maintains a persistentevaluator.Session. Each input line is parsed and evaluated; expression results are printed if non-void. - Check mode:
geblang check script.gbruns parse and semantic analysis only, reporting errors without executing. - Format mode:
geblang fmt script.gbruns the formatter (internal/formatter). - Build mode:
geblang buildcompiles the application and its dependencies into a self-contained binary (build.go,internal/bundle). - LSP mode:
geblang lspstarts the Language Server Protocol server (lsp.go,internal/lsp). - DAP mode:
geblang dapstarts the Debug Adapter Protocol server (dap.go,internal/dap).
Execution mode selection
runScript tries the bytecode path by calling loadOrCompileBytecode, which
either decodes a cached .gbc file (if the source hash matches) or calls
bytecode.Compile. If compilation succeeds, a VM is constructed and Run()
is called. If compilation fails (for example because a feature is not yet
implemented in the compiler), the function falls back to runEvaluator unless
--vm-only was passed.
The --trace-exec flag writes a one-line note to stderr indicating which path
(vm or evaluator) handled the script and, on the evaluator path, the
compilation error that triggered the fallback.
Bytecode module loader
The VM calls into a ModuleLoader implementation (bytecodeModuleLoader in
main.go) for each OpImportModule instruction. The loader:
- Resolves the module name to a file path via
modules.Resolver. - Reads and parses the source file.
- Compiles it to a
Chunk(or loads from the.gbccache). - Creates a fresh
VM, runs the chunk, and collects the exported values. - Caches the resulting
runtime.Moduleso subsequent imports of the same module in the same process return the cached value.
For modules that use stateful native functions (HTTP, database, etc.) the loader
holds a shared evaluator.Evaluator instance and routes StatefulNativeCaller
calls through it.
Adding a New Feature
The typical path for adding a language feature is:
- Add any new token types to
internal/token/token.go. - Update
internal/lexer/lexer.goto emit the new tokens. - Add AST node types to
internal/ast/ast.go. - Update
internal/parser/parser.goto parse the new syntax into AST nodes. - Update
internal/semantic/analyzer.goif the feature involves declarations that need structural validation. - Implement the feature in
internal/evaluator/evaluator.go. - Add opcode(s) to
internal/bytecode/bytecode.go. - Emit the opcodes in
internal/bytecode/compiler.go. - Handle the opcodes in
internal/bytecode/vm.go. - Add parity tests in
internal/bytecode/parity_test.goto verify that the evaluator and VM produce the same result. - Update documentation in
docs/user/and add examples inexamples/.
Features typically land in the evaluator first (steps 1-6) and get compiler/VM support (steps 7-10) as a follow-up. Until both paths support a feature, programs using it run via the evaluator fallback.