Skip to content

The two runs

An app is one file. It is run two ways, and the whole point of Gomamochi is that the two are the same program. This page is what each run is made of, what they share, and the few places they are not the same.

Both runs are Go, and only one of them is the Go compiler. When you ship, go build compiles your file into the binary you hand out. While you write, the same file is read by an interpreter, embedded in the gomamochi command. That asymmetry is the reverse of the one the other languages on this engine live with: there the interpreter is the reference and the compiled run is the one that could differ; here the compiled run is the reference, and the interpreted run is the one that could. Every difference between the two comes from that, and so does the reason check refuses a handful of shapes before anything runs.

One language, one file

Gomamochi defines no language of its own and translates nothing. What you write is Go, given a library:

What it takes
go build all of Go 1.25. It is what your app ships as, and check type-checks your file with the same go/types the compiler uses, so an error there is Go's own, in Go's own words.
yaegi the Go of 1.22, as an interpreter: generics, closures, goroutines, channels, and the standard library's packages compiled into the command. Not min and max, not range over a number or a function, not %T on the app's own types, not reflect, unsafe, cgo or //go:embed, and not a module outside the standard library yet. gomamochi check refuses those before anything is built: What Gomamochi refuses.

On top of that, Gomamochi adds one thing, and it is a library rather than a language: the elements that build the screen, and Run, Every, Task, SqliteExec and their neighbours. Your structs, your methods, your goroutines and Go's own packages are Go's.

What runs while you are writing

$ ./bin/gomamochi run app.go

That is the whole of it. The command is a Go program with yaegi (pinned at 0.16.1), the standard library's symbols and the door compiled into it, where the door is the package that opens the engine's library through purego. It reads your file, hands it to a fresh interpreter, and the interpreter calls the same compiled package your binary will. Nothing above the door knows which run this is.

One rewrite happens before the interpreter reads the file. Since Go 1.22 a loop variable is one per iteration, so a closure that captures it sees its own; yaegi keeps the older rule. So every loop variable a closure captures gets a copy of its own at the top of the loop's body, on the same line as the brace, so that line numbers stay where they were and the interpreter sees what the compiler would have given the closure. The one shape the copy would hide, a three-clause loop whose body writes the variable, is refused instead.

A save while the window is open reads the file again. A new interpreter takes the new file, the Run it reaches finds the window already up, and the app the window was holding is carried field by field, by name and type, into the new one. The values survive; a field that changed its type starts over.

What runs when you ship

$ ./bin/gomamochi build app.go --release

go build, with CGO_ENABLED=0, on the file as you wrote it: no translation, and no rewrite either, because the loop rule is the compiler's own. The binary links nothing but the system's own libraries and opens the engine's library beside it when it starts; --app puts the two in one bundle.

What both of them drive

One engine, behind a C face: crates/pixie-capi, which is pixie's kernel with gpui drawing, built as one shared library. Both runs open it through purego, without cgo. This is the one place Gomamochi's shape differs from its siblings': both runs cross the C face, and neither links the engine statically.

The face is deliberately narrow. An element is opened, written into by number, and closed; a handler is a number the door hands out; what the engine calls back with is integers only. The engine never holds a Go value, which is why one implementation can serve an interpreter and a compiled binary without knowing which it is talking to.

Those numbers are not written by hand either. elements.toml is the one table: every element, every keyword it takes, its type and its default. go run ./tools/gen writes elements.go (one type per element, one method per keyword) and the interpreter's view of the package from it, and the sweep fails when either is behind the table. An element cannot come to mean one thing in Go and another where it is drawn, and the interpreter cannot see a different package from the one the binary links. The other three languages on this engine read the same table.

Where the two are not the same

The compiler is the specification: where the two differ, the compiled run is right and the interpreted run has a bug. What follows from that is what a gate tells you. A green gate says the window you were writing in was showing you what ships. The differences come in three kinds, and it is worth knowing which kind you are looking at, because they are caught by three different things.

1. Refused before it runs

Most of the gap is closed by subtraction. A shape the interpreter stops on, or runs and quietly answers differently, is refused with the line and the rewrite, before anything is built. check reads the file with go/parser, which needs no toolchain, and, when go is on the path, type-checks it with go/types against the toolchain's own export data, which is what tells a range over a map, a number or a function apart. What Gomamochi refuses is the whole list, quoted from the fixtures that hold the wording.

Three of those deserve naming here, because they are Go a Go programmer writes without thinking. min and max are Go 1.21's, and the interpreter does not know them: write the comparison out, or a small function of your own. for i := range n is Go 1.22's, and the interpreter stops on it: write the three-clause loop. And for k := range m over a map is refused in a view, because a map walks in a different order every run; keep a sorted list of the keys on the app, made in a handler, which may range over the map freely.

2. Answers differently

Go's own standard library is the same compiled code in both runs. The interpreter calls the strconv, strings, math and sort compiled into the command, and the binary links the same packages, so there are no twins to keep honest and no table of what Go prints: a number formats the same, an integer overflows the same, a string upper-cases the same. What the interpreter sees, though, is the standard library's table as of Go 1.22; what the library gained after that is not there.

Two things do answer differently. %T prints the interpreter's name for an app's own type, not Go's, so it is refused. And a runtime panic is worded differently by the interpreter; an app that recovers one and draws its text is a difference the gate catches, and nothing refuses yet.

The framework's own standard library is one implementation. A database, the clipboard, the platform's dialogs, sound and notifications are the engine's, reached through the C face by both runs, so what the gate compares there is one library answering twice. Files, the network, JSON and the clock are Go's own packages, os, net/http, encoding/json and time, the same code in both runs.

3. The same program, at different speeds

The compiled run is faster. The arithmetic of a frame of the canvas games ran about a hundred times slower interpreted, and still took under a tenth of a millisecond, well inside a frame; a recursive fib(25) was two hundred times slower. A long computation that merely feels slow while you are writing is not a difference in behaviour, and a handler that blocks freezes the window in both, which is what Task is for.

The clock

Both runs tick off one clock. A timer declared with Every fires on a frame in a window and on an advance:<ms> step under a script, so the same number of ticks lands in both. A view may not read the wall clock, and check refuses time.Now there; a handler or a timer reads it and keeps what it read on the app, and both runs then draw what the app holds.

What that leaves you to check

The gate. One script, both runs, byte for byte:

$ ./bin/gomamochi gate demo/counter.go --script "click:+1,dump"
GATE OK — 3 dump lines identical in both runs

A green gate says the two runs agree on everything the script touched. It says nothing about what a script never reached, and nothing at all about the framework's own library, where one implementation answers both runs and a mistake in it is a mistake in both. That is what the sweep is for: every demo through the gate, the refusals against the fixtures that hold their wording, and every complete example in the tour. Together they are the claim: the compiled run is the Go compiler's, and the window you were writing in agreed with it.