mini-go
September 5, 2026 ยท View on GitHub
The second front end for cpp-vmlib's Core-IR, and a narrower one than
PL/0 on purpose: it exists to prove six of vmlib.h's own recipes work
end to end, against a real oracle, rather than to be a Go implementation.
See the top-level README's Fixed-width integers, float,
Static calls, Struct fields, Switch and Strings and slices
sections for what each recipe says a front end should do; this front end
is those sections turned into running code, checked against go run.
A source file here is real Go -- every sample in samples/ also runs
unmodified under go run, and each sample's golden/ output was captured
that way, the same relationship PL/0's samples have to culebra's own PL/0
interpreter. What is covered: top-level funcs with typed params and an
optional return type, var with an explicit int32/int64/uint32/
float32/float64/bool/string or []T type, assignment, return,
fmt.Println, arithmetic (+ - * / %), comparison (== != < <= > >=) and
type-conversion (int32(x), float64(x), ...) expressions, type ... struct declarations with field access and assignment (p.X,
p.From.X = v, any depth), slices of a scalar ([]int64{}, xs[i],
xs[i] = v, xs = append(xs, v), len(xs)), strings ("..." with the
four escapes \n \t \\ \", +, all six comparisons, len(s)),
switch over an int32/int64/uint32 subject with case a, b: and
default, if/else, the condition-only for, and goroutines with
unbuffered channels (go f(args), chan T, make(chan T), ch <- v,
<-ch) -- see Goroutines below. What is deliberately absent: methods,
multiple return values, maps, select, buffered channels, a call to a
result-less func as a statement, a slice whose element is anything but a
scalar, and a struct as a func parameter or return type -- none of those
exercise this front end's recipes any further, and a struct crossing a
call boundary raises value-semantics questions (does the callee's copy
alias the caller's?) that are orthogonal to what Struct fields is here to
prove.
Running
build/examples/mini-go/mini-go [--dump-ir] [--dump-bc] PROGRAM.go
A worked example: one closure, two calls
samples/ints/ints.go calls a top-level square(x int32) int32 twice from
main. --dump-ir on it shows the Static calls recipe concretely:
func #2 square locals=1 captures=0 singleton
...
callvalue @20:14
makeclosure square #2 cmap=0 @20:14 ; square(50000)
...
callvalue @21:14
makeclosure square #2 cmap=0 @21:14 ; square(3)
Two MakeClosure nodes, one closure: square is marked singleton, so the
executor builds its closure at whichever site runs first and hands the same
object to the other. The binder writes nothing extra -- no cell reserved in
main's frame, no preamble ahead of its body -- and a call site reached a
million times still allocates once. PL/0 does not need this (its procedures
are called once each in every sample it has), but a Go-shaped call site,
possibly reached many times, is exactly the case Static calls describes.
samples/switch/switch.go's classify has a case 1, 2: -- one body,
two keys. --dump-ir shows the two keys pointing at the identical block
(same source position, same contents, because it is the same NodeId
compiled twice, once per position it is referenced from):
switch @8:2
varref local[0] @8:9
literal 1 @9:7
block @9:2
return @10:3
wrapi32 @10:10
literal 100 @10:10
literal 2 @9:10
block @9:2
return @10:3
wrapi32 @10:10
literal 100 @10:10
...
The top-level README's Switch section describes exactly this recipe for
Go's case a, b:; this is it, generated by a real front end rather than
hand-built for a test.
Structs are values: Binder::copy_struct
samples/structs/structs.go builds var l Line = Line{From: origin, To: p} and
then writes through l.To.X -- and in real Go that write must not be
visible through p, the variable the literal read To's value from. It
was, on this front end's first pass: a struct here is an ObjectObj, the
same reference-counted heap value Index/SetIndex and FieldGet/
FieldSet already agree on, and just reading p's NodeId back into the
literal's To slot means l.To and p end up the exact same object,
not two that happen to hold equal fields. The output diverged from go run at exactly the sample above: p.X had moved to 999 along with
l.To.X.
This is the top-level README's Scope section, concretely: "struct value
semantics -- the front end emits explicit copy code". copy_struct
(binder.cc) is that code, called at every point a struct-typed value
crosses into new storage -- a var, a plain assignment, a field
assignment, a struct literal's own field -- and it is unconditional except
for one cheap check: a value
already built by a struct literal right there (Tag::ObjectLit) is
already fresh, so copying it again would only cost an allocation, not fix
anything. Everything else gets a field-by-field FieldGet into a new
ObjectLit, recursing so a struct nested inside a struct (Line.From,
itself a Point) is copied too rather than its own ObjectObj carried
over by reference.
Goroutines: coroutines plus the scheduler, and a channel written in IR
samples/goroutines/goroutines.go is the top-level README's Coroutines
and Scheduler sections turned into running Go, checked against go run. Three things the binder does, none of which needed a change to
vmlib.h:
go f(a, b) is Enqueue(CoroCreate(wrapper)). The arguments are
evaluated at the go statement (Go's rule) into fresh cells of the
current frame -- CellFresh first, so a go inside a loop gives each
goroutine its own values -- and a synthesized wrapper func captures the
callee's closure and those cells and makes the real call. The wrapper is
lenient_arity, since the scheduler resumes a coroutine with one
argument (nil) and a Go func may take none.
main is a goroutine too. funcs[0] is a bootstrap ($entry) that
spawns main as the first coroutine and returns, and the scheduler
drains the queue from there. That is what lets main block on a channel
-- a CoroYield in vmlib's entry frame would have no coroutine to
suspend -- and what makes the end-of-run deadlock check Go's "all
goroutines are asleep" rather than a hang. One difference to know: Go
ends the program when main returns, this scheduler runs the goroutines
still runnable to their own ends, so a sample whose output must match
go run synchronizes through channels rather than leaving output
pending in a goroutine.
A channel is an object, and its two operations are funcs in IR. An
unbuffered channel is {recvq: [...], sendq: [...]}, each queue holding
{co, value} waiters. $chan_send and $chan_recv
(Binder::emit_channel_runtime) are ordinary funcs built once per module
and marked singleton the way any user func here is; the $
keeps them out of the source language's reach. Go's rendezvous rule -- a
sender with a receiver waiting hands the value over, wakes it with
Enqueue, and goes on; one without parks itself (CoroCurrent() into
the queue, then CoroYield) until a receiver takes the value and wakes
it; symmetrically for a receiver -- lives here, not in vmlib.h, because
it is Go's rule and not every language's. The library supplies only
CoroCurrent, CoroYield and Enqueue.
The sample's every print is ordered by a channel handshake, so its output is the same under Go's scheduler (which may run goroutines in parallel) and vmlib's (one at a time, one thread).
Slices and strings: what the recipe leaves to the language
samples/slices/slices.go and samples/strings/strings.go are the top-
level README's Strings and slices section as running Go. Most of it is
a direct lowering -- a []T is an ArrayObj and xs[i] is Index, a
string is the executor's own Str and s + "x" is Add, len is the
Len intrinsic for both -- and the two places it is not are worth stating.
A slice aliases; a struct does not. var alias []int32 = seeded
leaves both names on one ArrayObj, so a write through either is visible
through the other -- which is exactly Go, and exactly why none of this
needs the copy_struct the struct samples do. The two rules sit next to
each other in emit_local_value: copy_struct is a no-op for everything
that is not Type::Struct, and that is the whole difference.
append is a statement here, not an expression. Go's append returns
a new slice header and may or may not reuse the backing array; whether a
later write through the result is visible through the original depends on
the capacity the original happened to have. ArrayPush is unconditionally
the reuse-it case -- it grows the one ArrayObj in place -- so the two
agree only in the shape where there is no second header to disagree
through. Binder::emit_append requires that shape syntactically:
xs = append(xs, v), target and first argument the same variable, and
anything else (ys = append(xs, v), an append in an expression) is a
diagnostic rather than a program that would have printed something go run does not.
len answers int64, not Go's own int -- a type this front end does
not have. That costs nothing, because the conversion real Go needs anyway
(int64(len(s)), since int and int64 are distinct types there) is a
no-op here.
A slice's element is one of the seven scalars and never a struct, a
channel or another slice: TypeRef carries one second_half, and an
element that needs its own has nowhere to record it. []Point is a
diagnostic, not a silently unchecked type.
Three grammar pitfalls worth knowing if you touch grammar.h
no_ast_opt. ret, print and neg each have exactly one child (the
thing they wrap), and params/args/stmts/program are lists that can
happen to hold exactly one item -- both shapes fold away under peglib's
optimize_ast unless marked no_ast_opt, taking the wrapper's identity (a
return becomes indistinguishable from evaluating its expression as a
statement) or the list's shape (a single-argument call's args node
disappears, taking that argument's position with it) with it. See
grammar.h's own header comment and examples/pl0/grammar.h's var note for
the canonical case this bites.
__ after ident. The word-boundary check __ only does anything
right after a bare keyword literal ('func', 'var', 'return') --
ident/type/number already end in a plain _ of their own, so by the
time anything after one of them runs, the separator is already consumed and
__'s lookahead is looking at the next token's first character, which
always fails. param <- ident type and vardecl <- 'var' __ ident type ...
want nothing there at all, not __ -- ident's own trailing _ is
already the separator -- and this front end's grammar had exactly this bug
on its first pass, caught by mini-go square.go failing to parse its own
parameter list. structdecl had the same kind of bug on slice 2's first
pass ('type' __ ident __ 'struct' ...), but there a plain _ really was
the fix: 'struct' is a keyword literal, not another ident, so it has no
trailing _ of its own to fall back on. Caught by every struct sample
failing on its own type ... struct line.
structlit has to be tried before slicelit. slicelit <- type '{' _ args '}' _, and type's last alternative is a bare identifier, so
Point{} matches it too -- with an empty args -- and would bind as
"Point is not a slice type". The reverse never happens: []int64{} cannot
match structlit, which starts with an ident. So ordering structlit
first costs nothing and is the only order that parses both.
Testing
ctest -R mini-go-samples runs
samples/{ints,floats,structs,switch,goroutines,slices,strings}/*.go
and requires the output to match a golden file (samples/golden/)
captured from go run -- see PL/0's own README for why an external,
independent oracle is what "passing" means here, not just this binary
agreeing with itself. Each
sample lives in its own subdirectory because it is a real, standalone go run-able package main; two of them sharing a directory would both
declare func main, which go build (though not the single-file go run
this test itself uses) rejects.