Skip to main content

01. Language surface

This note enumerates the Mochi surface forms the Go transpiler must accept and the Go shape each lowers to. The exhaustive table is in MEP-54 §3; this note frames the categories and calls out the cases that drove specific Go-target decisions.

Scope

Mochi's surface is roughly: scalars, control flow, records, sum types, closures, lists / maps / sets, query DSL, Datalog, agents, channels, streams, async, try / catch / panic, FFI, fetch, JSON, LLM generate. Each maps to a Go idiom, but several mappings have non-trivial trade-offs.

Scalars and control flow

int is int64 (never bare int). float is float64. bool is bool. string is Go's string (UTF-8 byte sequence). Arithmetic uses native ops; integer division and modulo route through mochiruntime.DivI64 / ModI64 so that vm3's "panic code 5 on zero divisor" semantics are preserved exactly (Go's built-in / and % already panic on zero for ints, but the panic message format differs from vm3's; routing through the runtime gives uniform panic codes).

for i in lo..hi lowers to a C-style for i := lo; i < hi; i++ loop with i retyped to int64 so the loop variable matches Mochi's int pin. for x in xs lowers to for _, x := range xs. The integer-pin to int64 choice (rather than int) avoids platform-dependent overflow behaviour on 32-bit targets; see type-lowering for the reasoning.

Records and sum types

record and anonymous type X = { ... } both lower to:

type Foo struct {
A int64
B string
}

The fields are exported (uppercase) so reflection-based helpers (fmt.Sprintf("%+v", foo), json.Marshal) work without per-field tags. Equality is field-by-field via Go's built-in == operator when all fields are comparable; for record types with slice or map fields the lowerer emits a generated Equal(other Foo) bool method.

type T = A | B lowers to a discriminated interface with one final struct per variant:

type T interface{ isT() }
type A struct { X int64 }
func (*A) isT() {}
type B struct { Y string }
func (*B) isT() {}

The marker method (isT()) prevents external types from satisfying the interface. match e { A(x) => arm } lowers to a type switch: switch v := e.(type) { case *A: x := v.X; arm; ... }. The match-to-decision-tree pass (Maranget 2008) is reused from the C target via clower.

Self-referential variants (type Tree = Leaf | Node(Tree, Tree)) wrap the recursive position in an interface field, which gives free indirection without an explicit Box<...> (unlike Rust).

Closures

Go closures are first-class: func(x int64) int64 { return x + 1 } works directly. Captures default to by-reference, which is wrong for Mochi's by-value capture semantics when the same closure runs in multiple goroutines reading the same captured variable. The lowerer emits an explicit ClosureEnvStmt that snapshots captured variables into a heap-allocated env struct at the point of closure creation; the closure body reads from env.X rather than directly from the outer x.

Function types lower to Go function types: fun(int) int becomes func(int64) int64. There is no Box<dyn Fn> overhead like Rust; Go function values are a (code pointer, env pointer) pair.

Recursive closures use the lifted-env pattern: the env struct holds a func(int64) int64 field that is filled in after the env is allocated.

Collections

list<T> is []T. map<K, V> is map[K]V. set<T> is map[T]struct{} (idiomatic Go set). omap<K, V> is a small generic helper mochiruntime.OMap[K, V] that pairs a map[K]V with an insertion-order []K for deterministic iteration.

List builtins (append, len, slice syntax xs[lo:hi]) lower to Go's native operators with no helper indirection. Higher-order helpers (map, filter, reduce) lower to runtime helpers that use Go 1.18+ generics: func Map[T, U any](xs []T, f func(T) U) []U.

Channels and streams

chan<T> is Go's native chan T. make_chan(N) is make(chan T, N). send(ch, v) is ch <- v. recv(ch) is <-ch. The runtime helper layer is thin to nonexistent here: Go's channel semantics are exactly Mochi's bounded blocking channel.

Streams (make_stream, subscribe, emit, recv_sub) lower to a runtime struct holding []chan T subscriber slots, each with bounded capacity for backpressure. subscribe_limit(s, N) controls the per-subscriber capacity.

Agents

agent A { state x: int = 0; on tick { x = x + 1 } } lowers to:

type AAgent struct {
in chan AMsg
X int64
}

func NewA() *AAgent {
a := &AAgent{in: make(chan AMsg, 64)}
go a.run()
return a
}

func (a *AAgent) run() {
for m := range a.in {
switch m := m.(type) {
case *ATickMsg:
a.X = a.X + 1
_ = m
}
}
}

spawn AgentType() calls NewAgentType(); the goroutine spawn happens inside the constructor. a.intent(arg) lowers to a send on a.in of the corresponding message struct.

Try / catch / panic

try { body } catch e { handler } lowers to an IIFE with defer + recover:

func() {
defer func() {
if r := recover(); r != nil {
e := r.(int64)
_ = e
handler
}
}()
body
}()

panic(code) lowers to panic(int64(code)). Go's recover is per-goroutine, which means an unrecovered panic in an agent goroutine terminates the program; Phase 10 documents this and provides an opt-in supervisor wrapper.

Query DSL and Datalog

from x in xs where p select e desugars at clower time into aotir.QueryExpr; the Go lowerer emits a straight-line for _, x := range xs { if p { result = append(result, e) } } loop. Joins, group-by, and order_by use the patterns in dataset-pipeline.

Datalog (fact parent(...), rule ancestor(...) := ..., query parent(_, Y)) is evaluated at compile-time via semi-naive fixpoint in transpiler3/go/lower/datalog.go and emitted as a frozen []Tuple literal.

FFI

import "C" is Go's stable cgo mechanism. Mochi's extern declarations lower to a cgo block:

/*
#include <stdio.h>
*/
import "C"

func printHello() {
C.printf(C.CString("hello\n"))
}

cgo is unavailable on GOOS=wasip1; Phase 12 documents the skip.

Builtins

print / println / printf lower to fmt.Println / fmt.Printf. File I/O wraps os.ReadFile and os.WriteFile. CSV uses encoding/csv. JSON uses encoding/json. HTTP uses net/http. The runtime module wraps these so that error semantics match vm3 (typically "return empty value on error" rather than "return error code").