This document summarizes the existing Rust implementation of Loon as it
actually is today, to anchor the self-hosting effort. It covers the four
things the bootstrap plan needs pinned down — the surface AST/Form type, the
effect-row representation, the hygiene approach, and the codegen target — plus
three divergences between the plan's assumptions and the real code that
need a decision before Stage 0 work proceeds.
Everything below is grounded in the current tree under
crates/loon-lang/src. File:line references are to that crate.
The reader produces Expr, not a minimal Form. It is richer than the
plan's sketch (symbol/keyword/number/string/list/vector).
// ast/mod.rs:19
pub struct Expr { pub kind: ExprKind, pub span: Span, pub id: NodeId }
// ast/mod.rs:48
pub enum ExprKind {
Int(i64), Float(f64), Bool(bool), Str(String),
Keyword(String), Symbol(String),
List(Vec<Expr>), // [head args...]
Vec(Vec<Expr>), // #[a b c]
Set(Vec<Expr>), // #{a b c}
Map(Vec<(Expr, Expr)>), // {:k v ...}
Tuple(Vec<Expr>), // (a, b)
Quote(Box<Expr>), // `expr
Unquote(Box<Expr>), // ~expr
UnquoteSplice(Box<Expr>), // ~@expr
DotAccess(Box<Expr>, String), // expr.field
}Key facts:
- Spans are byte-offset only —
Span { start: usize, end: usize }(syntax/mod.rs:4). No line/column.mergewidens to cover children. Every node carries a span and a process-uniqueNodeId(u32)(ast/mod.rs:9) used as the key for type-checker side tables. - Numbers are split at read time into
Int(i64)/Float(f64). Numeric literals may carry unit suffixes (10m,5.0kg) which the parser desugars to[unit 10 :m]. - Several "reader macros" are desugared in the parser, not a macro pass:
string interpolation
"hi {name}"→[str "hi " name]; the postfix?operator → amatchonOk/Err; unit suffixes as above. - Comments are not in the AST.
parse_with_commentsreturns(Vec<Expr>, Vec<Comment>)whereCommentcarries aSpan; the formatter re-attaches them by source position. - A second, post-typecheck AST exists:
TypedExpr { kind, ty, span }(ast/typed.rs:8) with aTypedExprKindthat drops the quote/unquote variants (they're gone by then) and carries a resolvedType.
Implication for Stage 0: the shared Form type must be the full surface
set above (including Set, Map, Tuple, DotAccess, and the three quote
variants) if read(fmt(x)) round-trips are to hold against the Rust corpus.
A reduced Form will not round-trip the existing .oo/.loon corpus.
(This section originally documented a divergence: effects were a flat
EffectSet accumulated in a checker side-channel, with no tail variable and
no unification. That is no longer true — the effect-rows track landed
row-based inference in the Rust checker.)
The plan's assumption — "effect rows unify alongside type rows" — now holds. Both row mechanisms exist and are structurally analogous:
-
Type-level rows (for records) — row types with a tail variable:
Row(Vec<(String, Type)>, Option<TypeVar>) // None = closed, Some = open
unified structurally by
unify_rows(types/mod.rs). -
Effect rows — real rows with an optional tail variable:
pub struct EffectRow { pub labels: BTreeSet<String>, // concrete effect labels pub tail: Option<TypeVar>, // None = closed, Some = open }
carried on function types as
Type::Fn(params, ret, EffectRow)and asType::Effects(EffectRow)for tail-variable bindings, unified through the mainunifyviaunify_effect_rows(types/mod.rs), with an occurs check for infinite rows.
The checker still keeps fn_effects: HashMap<String, EffectRow> and an
ambient current_fn_effects: EffectRow (check/mod.rs), but inference is now
row unification, not accumulation: performs push labels through the
ambient row's tail; calls link the callee's instantiated row into the
ambient row; handle subtracts handled labels and constrains the body row's
tail so handled effects cannot leak through it. The row tail generalizes
with let-polymorphism, so effect polymorphism (a compose/twice whose
effect row is inferred from its function arguments) is representable —
the homepage compose story now type-checks. Row mismatches surface as
E0403.
D1 status: the decision below predates this change. A row-based self-hosted checker can now aim for differential parity with the Rust checker on effect inference — the "no bridge / parity given up" rationale no longer applies.
- The docs claim hygiene:
guide/macros.loon— "Loon macros are hygienic by default (like Scheme'ssyntax-rules)." - The implementation is gensym-based binder renaming (not scope sets).
macros/mod.rssubstitutes overHashMap<String, Vec<Expr>>bindings keyed by raw symbol name, butexpand_templatefirst collects the binders the template itself introduces (letnames,fn/loopparams — seecollect_template_binders) and renames each to a per-expansiongensymthroughout the template. This prevents both capture directions for template-introduced temporaries (covered bymacro_tests.rshygiene tests and thehygiene.ooconformance program). It is NOT full scope-set hygiene: macro parameters, unquoted binders ([let ~name ...]), named-fn definition names, and match-pattern variables are left un-renamed, and a template's reference to a global function is not referentially transparent (a caller-scope local of the same name still shadows it).
Expansion is top-down recursive (expand_expr, macros/mod.rs:156), re-
expanding macro output so macros-producing-macros work. Two macro flavors:
- Template macros — body is a
`quote``; expanded bysubstitute(`macros/mod.rs:409`) walking the template, replacing `x` / `@xs`. - Procedural macros — body is arbitrary code, evaluated at expansion
time by the main interpreter (
interp::eval_program,macros/mod.rs:571) under a sandboxed effect allow-list (COMPILE_SANDBOX,interp/mod.rs:38). Allowed compile effects:IO,Net,Env,Print(compile_builtins.rs). So there is already one shared evaluator for macro bodies and runtime code — good, matches the plan's "don't fork two evaluators" constraint.
Decision needed (D2): the plan says use scope-set hygiene "unless ARCHITECTURE.md shows the existing expander uses another model — match what exists, flag if you'd change it." What exists is no hygiene. So this is a genuine fork: (a) match the Rust expander's unhygienic textual model (cheapest, keeps differential tests byte-exact, but the docs become a lie), or (b) implement scope-set hygiene in the Loon expander (matches the docs' promise and the plan's torture-test gate, but then the Loon expander is not differentially identical to the Rust one on capture cases — by design).
[and ...]/[or ...] in CALL position are desugared by the macro expander
into nested [if ...] with gensym temps, so they short-circuit identically
on the interpreter, the EIR VM, and wasm (all consume expanded programs).
Value semantics match the old eager builtins: first falsy (and) / first
truthy (or) operand, else the last one; [and] → true, [or] → false.
Three deliberate consequences (all pinned in the conformance corpus):
- Call-position
and/orare effectively special forms. The rewrite runs before any scope information exists, so a local binding namedand/orcannot shadow them at call sites (and-or-shadow.oopins this on every backend). Every other operator/builtin IS shadowable by a local. - Value-position
and/orfall back to the eager variadic builtin on the interpreter only. The EIR VM and wasm have no callable builtin VALUES (a pre-existing limit that applies to+etc. as well), so[let f and]errors there — pinned byand-or-value.oo. - Pipe steps get a dedicated rewrite:
[pipe v [or a]]is thread-last partial application ([or a v]), so and/or steps become unary-lambda steps wrapping the desugar (expand_pipe) instead of being desugared in place — pinned bypipe-and-or.oo.
Pipeline: source → parse → macro-expand → typecheck → lower → EIR → backend.
- EIR (
eir/mod.rs) is a flat, SSA-ish, basic-block IR.Opis the instruction set (~17 variants:Lit,Mov,Bin,Call,Invoke,Close, collection builders,Field/Tag, and the effect opsPerform,PushHandler,PopHandler); blocks end inRet/Jmp/Br/Switch/Tail/Recur/Trap. - Default backend is a register VM (
eir/vm.rs), invoked byloon runviaeir::vm::eval_eir. Values are NaN-boxed 64-bit (eir/value64.rs): tags for heap ptr, inline int48, interned symbol, and immediates. Two other backends exist: WASM (eir/wasm.rs) and a partial Cranelift native JIT (eir/native.rs). The old tree-walking interpreter (interp/) is explicitly superseded, retained only for--legacyand the WASM/DOM bridge. - Effects at runtime = handler-passing / evidence-passing, not CPS or
stack-copying delimited continuations. Handlers are lowered to closures;
PushHandler/PopHandlermaintain a dynamic LIFO handler stack, and where the handler is statically known it's threaded as an evidence parameter for a direct call (eir/lower.rs:1428,eir/vm.rs:622). - Continuations are ONE-SHOT.
resumeis a pre-allocated identity closure (eir/vm.rs:140): calling it returns the supplied value to theperformsite and execution continues linearly. There is no reified, re-entrant continuation; the handler body runs once and the result flows back. Multi-shot resume (callingresumetwice, backtracking, non-determinism) is not supported by the current VM.
Decision needed (D3 — the plan's flagged STOP): the plan recommends multi-shot continuations and asks to confirm before codegen. The existing runtime is one-shot evidence-passing. Going multi-shot means a genuinely different runtime (reified resumable continuations / segmented or copied stacks), which the Rust VM does not have — so a multi-shot self-hosted compiler could not target the current VM unchanged, and the FIXPOINT gate (Loon compiler reproducing itself on the existing toolchain) gets much harder. One-shot keeps the self-hosted compiler aligned with the real VM.
The Stage 0 frontend (src/frontend, in Loon) can proceed as soon as D1–D3
are decided, because all three change what the shared definitions are:
- The shared
Formtype (§1) is well-defined and stable — safe to build now. - The shared effect-row type (§2, D1) is a prerequisite the plan says Stage 0 declares and Stage 3 reuses; we must pick set-vs-row first.
- Hygiene (§2/D2) and continuations (D3) gate Stages 2 and 3, not Stage 0
reading, but they shape the
src/frontenddata model (e.g. whetherFormsymbols need to carry scope-set slots) so they're cheaper to decide now.
Recommended path (my opinion, for confirmation):
- D1: define a real row-based effect representation in Stage 0
(fields = effect names, optional tail var) so the homepage
composeexample actually type-checks — but also keep a lowering to the Rust checker'sEffectSetfor differential testing. This is the one place I'd improve on the existing code rather than mirror it. - D2: implement scope-set hygiene in the Loon expander (honor the
docs), and run the differential test against the Rust expander only on the
hygiene-neutral corpus; treat capture torture-cases as Loon-only
conformance tests. Carry a scope-set slot on
Formsymbols from day one. - D3: one-shot for the bootstrap, to keep the FIXPOINT reachable on the existing VM; revisit multi-shot as a follow-up once self-hosting holds. (This contradicts the plan's "recommend multi-shot" — flagging explicitly.)
These recommendations are deliberately conservative about the fixpoint and
honest about the doc/impl gaps. I want your call on D1–D3 before writing
src/frontend.
D1 — Effect representation: row-based, no bridge. The self-hosted
frontend will define real effect rows (effect-name fields + an optional tail
variable) and infer them by unification. The homepage compose example
becomes expressible. (Update: the Rust checker has since moved to the same
row-based representation — see §2 — so the original caveat "differential
parity with the Rust checker is intentionally given up" no longer applies;
parity on effect inference is back on the table.)
D2 — Macro hygiene: scope sets. The Loon expander will implement
Flatt/Racket-style scope-set hygiene, honoring the docs. Form symbols carry
a scope-set slot from Stage 0 onward (the Vec field on FSym) so later
stages extend Form rather than redefining it. Differential testing against
the (gensym-renaming, see §3) Rust expander will be limited to cases where
the two models agree; scope-set-only torture cases become Loon-only
conformance tests.
D3 — Continuations: multi-shot. The compiler will target reified,
multi-shot resumable continuations. The current EIR VM is one-shot
(resume is an identity closure), so a multi-shot self-hosted compiler
cannot target today's VM unchanged; the Stage-3 FIXPOINT plan must account
for new runtime support (segmented/copied stacks or a CPS lowering). Flagged
as the largest risk to the byte-for-byte fixpoint.
Location: src/frontend/reader.oo (library), src/frontend/tests/ (drivers),
src/frontend/run-corpus.sh (harness). Written in Loon, runs on the Rust VM.
- Form is the single shared surface type (
FInt FFloat FStr FKw FSym FList FVec FSet FMap FTuple FQuote FUnquote FSplice FDot FQuery FError), every node span-carrying;FSymcarries the D2 scope-set slot. Numeric literals keep their lexeme text (a reader does not interpret numbers). - Diagnostics are effects, not exceptions:
Source.spanmints spans,Reader.errorreports malformed input (resumed with anFErrorplaceholder — error recovery, the reader keeps going). - Ship gate — round-trip read on the whole repo corpus: PASS. All 67
.oo/.loonfiles (samples, web docs, crates, docs). The gate is: the reader accepts the file (noFError) and the canonical form is an exact fixpoint,canon(src) == canon(canon(src)). Result:ok=67 errored=0 unstable=0 empty=0. Fidelity spot-checked: type decls,matchwith destructuring, pipes, nested closures, maps, keywords, and the large web pages all round-trip with top-level form counts preserved. 21 inline unit tests also pass.
These constrain every later stage too:
=is heap identity on strings ([= "a" "a"]isfalse); evenassert-eqis bit-equality. String/char equality must be built fromlenindex-of(streq). Keywords, symbols, and ints compare by value.
- The Rust lexer (which parses our Loon source) has no
\rescape —"\r"becomes the two chars\,r. Source is LF; we never write\r. - A bare
{in a string literal is a parse error (interpolation start); a literal open brace must be written\{. A bare}is fine. - Multi-file
usedoes not work on the VM (value is not callable), so the frontend is one file; stages are concatenated, not imported. IO.list-diris unimplemented (returns Unit);IO.read-fileworks. The corpus list is generated by the harness, not enumerated at runtime.loon rundoes not type-check, so theFormADT's field-type annotations are runtime-irrelevant.loon testuses the legacy interpreter, so we validate vialoon run(the real EIR VM) instead.foldis[fold init f coll](init first).
loon fmt rewrites the literal-brace escape "\{" to a bare "{", which is
then unparseable — so fmt is not idempotent (it emits output it cannot
re-read) for any string containing a literal {. Consequently a brace-handling
reader cannot be passed through the current loon fmt without corruption,
and the Stage-0 source is therefore kept hand-formatted in canonical style and
not run through fmt. This is a concrete defect Stage 1's self-hosted
formatter must fix (and a caution for Stage 1's "byte-identical to the Rust
formatter" gate: the Rust formatter's own output is invalid here).
Location: src/frontend/formatter.oo (concatenated after reader.oo).
A Wadler/Leijen pretty-printer (Doc = DNil | DText | DLine | DHard | DIndent | DConcat | DGroup) with a cons-list-stack renderer, mirroring the
Rust formatter's per-form layout (generic lists, fn/defn, let, if,
match pair-per-line, pipe, type/effect, vec/set/map/tuple) at
2-space indent / width 80.
Deliberately faithful, NOT byte-identical to loon fmt. On inspection the
plan's gate 2 (faithful round-trip) and gate 3 (byte-identical to the Rust
formatter) conflict on the corpus, because loon fmt is lossy:
- Floats: rendered via Rust
Display, so5.0→5(re-reads as an int) and3.140→3.14(verified empirically). - Literal braces:
"\{"→"{", unparseable (the brace bug above). - Interpolation:
"hi {x}"is desugared to[str "hi " x]and printed that way — the original literal is gone. - Blank lines / comments: preserved from the source buffer via a position-based attachment pass the reader does not reproduce.
A formatter satisfying gate 2 (read(fmt x) ≡ read x) therefore cannot be
byte-identical to one that rewrites 5.0→5. The self-hosted formatter keeps
float lexemes, literal braces, and interpolated strings, targeting gates 1
(fmt(fmt x)==fmt x) and 2 (canon(fmt x)==canon(x)), both PASS 65/65 on
the corpus (+ 21 inline).
src/frontend/comments.oo ports the Rust formatter's comment machinery: a
string-aware comment scanner plus build_attachments/attach_seq/attach_into
(leading / trailing / dangling per node, blank_before, program leading/
trailing), keyed by an encoded span. formatter.oo is now comment-aware
(trailing_doc/body_break/close_after/fmt_children and the special
match/map printers), and format_program emits program-leading/trailing
comments, per-form leading/trailing, and source blank lines.
Result: byte-identical to the float-fixed Rust formatter on every corpus file
free of parser-level desugaring — full corpus 45/65 (samples/*.oo went
from 8/21 layout-only to 19/21). Every one of the 20 remaining diffs is a
parser divergence, not a formatter or comment/blank one:
- String interpolation —
"…{x}…"→ the parser desugars to[str …]. - Literal braces — source
\{→ the parser's unescape+desugar collapses it to a bare{; the faithful reader keeps\{(which round-trips). This is the brace bug from §6: Rust's bare-{output does not re-parse. - Unit literals —
100.0m→ the parser desugars to[unit 100.0 :m].
(The 18 web .loon files differ only via interpolation / literal braces, which
saturate HTML-templating source.) Closing these needs the parser to preserve
surface syntax (or the reader to replicate its desugaring) — out of formatter
scope, and in conflict with gate 2 wherever the Rust desugaring is lossy.
Known edge: a comment placed immediately before a close bracket (dangling) is
where the Rust formatter has a latent bug (it lets ] share the comment's line,
producing non-round-trippable output) and the EIR VM additionally shows
attachment non-determinism for it; no real corpus file exercises this and the
corpus round-trip gate is 65/65.
Step 1 done: the Rust formatter's float defect is fixed —
crates/loon-lang/src/fmt/mod.rs now renders Float with {:?} so 5.0
stays 5.0 (re-reads as a float, not an int) instead of 5. Idempotent;
full Rust suite still 488/0.
Measured Loon-vs-fixed-Rust byte match on samples/*.oo: 8 identical, 13
differ — and every diff is blank-lines or comments, never layout. That is,
the self-hosted formatter's layout (indentation, group/break, and all
special-form rules) is already byte-identical to the Rust formatter; the only
remaining gate-3 gaps are metadata the Stage-0 reader drops by design:
- Blank-line preservation — Rust keeps source blank lines via a
position-based
blank_beforeset; the Loon side only inserts blanks betweenfn/type/effectdefns. (Smaller fix: have the reader record ≥2-newline gaps and thread them to the formatter.) - Comment preservation — the reader discards comments; Rust attaches them (leading/trailing/dangling) and re-emits them. (Larger: port the comment-attachment pass.)
Still parser-level (out of formatter scope): literal-brace round-trip (the
unescape/desugar \}→}} interaction) and interpolation ("hi {x}" is
desugared in the parser before the formatter sees it). Gate 3 is therefore
substantially closed (layout-identical); full byte-identity is itemized
above as reader-metadata + parser work.
Location: src/frontend/expander.oo (concatenated after reader.oo).
Done and tested (12/12 inline, 6/6 differential, 65/65 corpus):
-
Quasiquote / unquote / unquote-splicing as a
Form -> Formengine with nesting levels (nested`raise the level;~/~@fire at level 0). -
Template macros
[macro name [params]body], including&restparameters, an argument used multiple times, **nested macros**, and **macro-generating-macro** (output is re-expanded to a fixpoint); expansion descends into sub-forms and is identity (modulo layout) on macro-free source. Substitution matches the Rust expander exactly: a bound parameter is substituted even when **bare** (no~);name` / names pass through unchanged.bound to many values becomes aList; unbound -
Scope-set hygiene (D2), expander side: every identifier a macro introduces gets the macro use's fresh scope (carried in the
FSymscope-set slot reserved in Stage 0); identifiers coming from the macro's arguments are untouched. Verified: expanding[cap tmp]wherecapintroduces its owntmpyields a scoped introducedtmpand an unscoped argumenttmp— the no-capture mechanism. (Binding resolution over scopes is the Stage-3 resolver's job; hygiene is only observable through name resolution, so the end-to-end no-capture guarantee lands in Stage 3.) -
Effects: expansion-time diagnostics via
Expand.erroronly. The plan'sGensymeffect is replaced by deriving each macro use's scope from its call-site span start (unique per source position): the EIR VM cannot keep handler state across one-shot resumes, so a statefulGensymhandler would hand out a constant — span-derived scopes need no state. A threaded counter is the alternative if globally fresh ids are later required. -
Procedural macros
[macro name [params] #{effects} body]are recognized (the body sits after an effect set) and carried asstyle :procedural, so they are never mis-parsed as templates. Their execution — running the arbitrary Loon body at expansion time — is deferred: it needs the tree-walking evaluator (shared with Stage 3) and there is noevalbuiltin on the EIR VM to borrow. A procedural call expands to anExpand.errorplaceholder rather than wrong output. Implementing it is Stage-3 work (the evaluator) plus the AST-as-value map protocol ({:kind :symbol :name …}) and the compile-effect sandbox.
Differential test vs the Rust expander (run-differential.sh,
tests/macros/*.oo): each program uses macros and prints deterministic output;
we compare loon run PROG (Rust expands + runs) against self-hosted-expand →
write macro-free source → loon run. 6/6 behaviorally equivalent (simple,
rest+splice, nested, macro-generating-macro, arg-used-twice, bare-substitution).
A behavioral diff cannot exhibit hygiene (scopes don't serialize), so hygiene is
asserted at the Form level instead (inline). The expander is also a verified
no-op on the whole corpus (run-corpus.sh expand: 65/65, 0 changed).