writeonce/docs/plan/oop-vm/08-builtin-surface.md
shoney.arickathil 0df9b4fe31 feat(compiler): iteration 5 Tasks 1-4 — modules, language surface, switch, typedef records + enum variants
- Modules: `use`/`pub`, directory-as-module, per-module symbol resolution (a
  flat first-wins merge silently ran the wrong `pub fn` body), six reserved
  stdlib namespaces typed UNKNOWN-BUT-RESERVED.
- Surface: `and`/`or` (own precedence tier, short-circuit, Bool-only), `${}`
  interpolation desugared at parse time, `const`, break/continue with
  drop-correct exits, do-while, inline-fn rejection.
- switch expr/stmt: required `default` over scalars/Text, arm unification,
  EQ/EQS+JZ lowering, per-arm drop scopes with N-way JOIN-DROP; `default`
  sorted last by a shared lowering order (textual order made arms dead).
- typedef records: structural, same shape = one class entry; `?name: T`
  nullable-by-shape; emit_ctor fills omitted defaults; `type` as field name.
- Enum variants: all-bare unions = int ordinals; any-payload = one class
  entry per variant, tag IS the header class_id (no header field, no format
  bump); exhaustive switch without `default`; arity checked both directions.
- Payload escape modeled as move-out (pointer-kind fields only — a scalar
  escape is a copy); caller reaps owned heap temps passed by borrow: two
  unbounded LSan-blind leaks, 10.5 MB -> 1.5 MB flat over 300k iterations.
- Fixed en route, each with a RED repro: dead E209 builtin-arg check and
  `int_to_text` missing from both types.ml builtin tables (both segfaulted
  wovm), multi-file phantom double-report, emit_ctor's field temp clobbering
  dst in tail position (pre-existing), warnings swallowed without an error.
- Two fenced VM builtins: `int_to_text` (13), `variant_tag` (14).
- 14+565 unit (was 14+401), corpus 71 (was 32) plain and under wovm_asan,
  wovm-test + cli_smoke green. Log-watcher 307 -> 93 diagnostics (85 E101 /
  4 E207 / 1 E208 / 3 W202); the 5 non-E101 residuals await Task 7 grammar.
2026-08-12 14:40:07 +02:00

11 KiB

Milestone-1 source surface the emitter lowers — normative reference

What a .wo program may say and have woc produce bytecode for. The .wob format doc (00-wob-format.md) names the BUILTIN ids; this names their source spellings and the handful of rules that have no other home. Landed with the emitter (compiler/src/emit.ml, plan 3 task 1). Diagnostics referenced here are catalogued in 01-error-catalog.md.

Anything on this page is a contract for corpus fixtures and for every later sub-project's .wo code — not an emitter implementation detail.

Builtins

Containers and runtime services are free functions, never methods. Each maps to one BUILTIN id of the format doc.

source .wob builtin arity meaning
now() now 0 wall-clock milliseconds (Int)
print(t) print 1 a Text, newline-terminated
print_int(n) print_int 1 an Int, newline-terminated
words(t) words 1 whitespace token count of a Text
multi_new() multi_new 0 a fresh multi T — see the destination rule below
map_new() map_new 0 a fresh map<K, V> — see the destination rule below
push(m, v) multi_push 2 append to a multi
count(c) count 1 length of a multi or a map
latest(m) latest 1 last element of a multi (traps BOUNDS when empty)
get(c, k) multi_get / map_get 2 element by index, or value by key (a missing key traps KEY)
set(m, k, v) map_set 3 insert or replace in a map
has(m, k) map_has 2 1/0
int_to_text(n) int_to_text 1 decimal rendering of an Int, as a fresh owned Text — haxe-parity Task 2's one fenced VM addition, the type-directed half of string interpolation (below); also directly callable

get, set, push, count and has resolve on the container they are given, so one source name covers the multi and map ids the runtime keeps apart.

Sugar. c[i] is exactly get(c, i) and m[k] = v is exactly set(m, k, v). There is no element write into a multi — v1 has multi_push and multi_get and no element store — so m[i] = v on a multi is WO-E403.

Shadowing. A user-declared free fn of the same name always wins. A declared name is never silently replaced by a builtin.

push and @gc elements. push(m, v)'s value argument is never a resolved callee parameter (push has no declared signature), so the owner pass's ordinary Take-gated transfer never reaches it; a @gc value pushed into a multi is special-cased in owner.ml's analyze_call (the value escapes into the container exactly like a ctor field, RC_INC included) specifically so a multi-mediated @gc cycle can be built and later collected (tests/corpus/gc/, plan 3 task 5). set(m, k, v) has no equivalent special case — a @gc key or value handed to set is not retained, so a map<_, SomeGcClass> (or a @gc-keyed map) built this way will under-count its element's refcount and the collector will free it while the map still points at it. Nothing in the corpus exercises this yet; treat it as an open gap, not a proven-safe pattern, until set gets the same fix push did.

A fresh container needs a destination of declared type

multi_new() and map_new() carry their element (and key/value) kinds as an instruction immediate, and those kinds are the container's drop plan at runtime (runtime/src/gc.c). They cannot be guessed: assuming SCALAR for a multi Item leaks every element, and for a map<Text, _> leaks every key. Milestone-1 let has no container type annotation — its optional annotation is a bare identifier — so a fresh container must be created where its type is declared:

class Store {
  items:   multi Item
  by_name: map<Text, Int>
}

fn main() {
  let s = Store { items: multi_new(), by_name: map_new() }   -- kinds from the fields
  push(s.items, Item { n: 7 })
  set(s.by_name, "seven", 7)
}

A bare let m = map_new() is WO-E403, reported at the creation site. The same rule applies to a take/mut parameter of declared container type, which is also a typed destination.

Calls

  • Argument count must match the callee's parameter count (WO-E403). Nothing upstream checks arity — types.ml declares WO-E203 and never raises it — and a mismatched call reserves a register window the callee does not read, which the loader rejects outright.
  • A method is called as receiver.method(args). When the receiver's declared type is an interface, the call is dispatched by vtable (ICALL); satisfaction is structural (same method name, same parameter count), Go-style, with no implements keyword.
  • self occupies the callee's r0, so a method's argument count is 1 + parameters.

Modules (haxe-parity Task 1)

A .wo file's module is its directory — no manifest, no declared module name. Every file sharing a directory sees every other same- directory file's declarations unconditionally (Task 8's existing multi-file discovery, unchanged); a name declared in a different directory needs use to become visible at all, and even then only if it is marked pub.

  • pub on a top-level class/type/interface/fn exports it outside its own module. Default is private-to-module — visible to every file in the same directory, invisible to every other module regardless of use (WO-E217 if referenced anyway). pub on a class/interface method, or the pub(read) field-accessor marker (Haxe's (default, null)), is a different, later feature — not this one.
  • use fs (a bare, single-segment name) is a reserved stdlib namespace: exactly fs, proc, net, time, json, env, no others, and always stdlib even if a same-named project directory exists. A call through one (fs.stat(...)) typechecks as UNKNOWN-BUT-RESERVED — no E207/E225/arity check, since the six namespaces' members arrive in plan 9 — and is WO-E406 only if such a call survives all the way to emission; an unused use fs compiles clean (modulo WO-W202, below).
  • use shared/util (slash-separated segments) is project- relative: it must name a directory this program's own discovery actually finds, or WO-E216. The alias a call site uses is always the path's last segment (util.fn(...), not shared.fn(...)).
  • Resolution order for a bare (unqualified) name: this file's own module, unconditionally; then every used module's pub surface. If more than one used module exports the same pub name, that is WO-E218 — collisions diagnose rather than silently pick a winner. A qualified reference (alias.name(...)) skips straight to its named module; WO-E217 if name exists there but isn't pub.
  • A use clause never referenced (bare or qualified) anywhere in its own file is WO-W202 — a warning, so it does not fail the build.

Program entry

The entry point is the zero-argument free fn main. A main that takes parameters is not an entry (the format's own rule is a zero-argument free fn), and wovm will report module has no entry method.

Operators with no dedicated opcode

Lowered by the emitter, not added to the format:

source lowering
a % b a - (a / b) * b — exact for the VM's truncating DIV, which traps on 0 and on INT64_MIN / -1, both correct for % too
a != b (a == b) == 0
a > b, a >= b LT / LE with the operands swapped
a == b on Text EQS (content equality); EQ otherwise
a .. b CONCAT — + is arithmetic only, never string addition
a and b evaluate a; JZ past evaluating b (result stays a's value); else evaluate b into the same register (haxe-parity Task 2)
a or b evaluate a; JZ + JMP past evaluating b when a is already true; else evaluate b (haxe-parity Task 2)

Not lowerable in milestone 1

Each is WO-E403 at the offending site, never invented bytecode:

  • an element write into a multi (no element-store instruction);
  • for over a map (v1 exposes no key enumeration);
  • a name that is neither a local, a parameter, self, a declared fn, nor a builtin;
  • a field or method on a type that is not a declared class — including a class named only inside multi T / ref T, which types.ml's unknown-type check (WO-E225) does not look inside.
  • break/continue outside any loop (haxe-parity Task 2) — nothing upstream tracks loop nesting to reject it earlier, so the emitter's own "no legal jump target" gate is the only one.
  • interpolating ("${expr}") a value that is neither Text nor Int (haxe-parity Task 2) — the brief's own scope; a class, multi, map, or other scalar has no defined textification here.

Small control surface (haxe-parity Task 2)

break/continue/do...while, const, and/or, and string interpolation — the haxe keyword verdict table's low-risk batch, added 2026-08-11.

  • break/continue reuse the owner pass's own return-drop machinery, bounded to the nearest enclosing loop instead of the whole function: an owned value still alive in the loop body is dropped at the break/continue site itself, not left to leak (proven under ASan, tests/corpus/run/lang-break-owned-drop/). continue's actual jump target depends on loop shape — while's own condition check, for's increment step, or do...while's condition check — but the drop-set computation is identical either way.
  • do { body } while cond — the body always runs at least once; lowered onto the same JZ/JMP pair while/for already use, just reordered.
  • const NAME = <literal> (top-level, or bare — no static — class-level) is resolved entirely by parser.ml, before typecheck ever runs: every unshadowed reference is replaced by the literal it names, so nothing downstream (types/owner/emit) has any const-specific code at all. A local/parameter/self of the same name always shadows it. static const is Task 7's own syntax (static), not recognized here.
  • and/or are real keywords (never &&/||), one precedence level below comparison (or loosest, then and, then comparison — so a == 1 and b == 2 needs no parens). Bool-typed operands only, no truthiness: a confidently-non-Bool operand is WO-E201. Short- circuit, lowered to compare-and-jump above — no new opcode.
  • String interpolation ("${expr}") desugars at parse time to a .. (Concat) chain of text segments and embedded expressions; each embedded expression's textification is decided at emit time, once its type is known: Text passes through untouched, Int is wrapped in int_to_text (above), anything else is WO-E403 (see "Not lowerable in milestone 1"). \$ is a literal $ (so \${x} stays literal, never interpolates); a lone $ not followed by { is also literal, unconditionally.

?T

A nullable field stores exactly what T stores and spells nil as 0. The v1 format has no kind byte for it (field kinds run 0..5; the loader rejects 6), and it needs none: every per-kind drop plan already ignores a zero slot. ?T's field kind is therefore T's. Note the consequence for @gc: ?SomeGcClass is a GCREF field like any other, so it participates in refcounting and cycle detection normally.