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.