Compiler research (#10)
adds self-hosted compiler is functionally: - The default compile path is the portable pipeline using jolt.analyzer (Clojure) → host-neutral IR → backend.janet. - The analyzer is itself Clojure, compiled by jolt for true self-hosting. - bootstrap-fixpoint passes (stage1 == stage2 == stage3): rebuilding the compiler on its own output. - clojure.core is now self-hosted in the overlay. - Stateful forms (defmacro/ns/deftype/defmulti/require/in-ns) are interpreted by design.
This commit is contained in:
parent
607779866e
commit
d3194aae59
68 changed files with 6590 additions and 2019 deletions
138
doc/self-hosting-architecture.md
Normal file
138
doc/self-hosting-architecture.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# Self-hosting architecture: portable jolt-core over a host runtime
|
||||
|
||||
Design for splitting Jolt into a **portable Clojure-in-Clojure core** and a
|
||||
**host runtime** (Janet today, another runtime tomorrow), so the language is
|
||||
truly self-hosted and `jolt-core` can be lifted out and re-hosted.
|
||||
|
||||
This is the design that must be right *before* writing the compiler in Clojure —
|
||||
see [[self-hosting-compiler]] for the staged plan it plugs into.
|
||||
|
||||
## What "truly self-hosted + portable" requires
|
||||
|
||||
Two independent properties:
|
||||
|
||||
1. **Self-hosted** — the compiler and most of `clojure.core` are written in
|
||||
Clojure and compiled by Jolt itself.
|
||||
2. **Portable** — that Clojure code (`jolt-core`) depends only on a small,
|
||||
explicit **host contract**, never on Janet directly. Re-hosting means
|
||||
implementing the contract for a new runtime; `jolt-core` is reused verbatim.
|
||||
|
||||
The enemy is `jolt-core` calling `janet/tuple`, `make-vec`, `ns-find`, etc.
|
||||
directly — that welds it to Janet. Every host dependency must go through the
|
||||
contract.
|
||||
|
||||
## Prior art (the seam everyone uses)
|
||||
|
||||
- **Clojure (JVM).** `clojure.lang.*` (Java) is the host: `RT`/`Numbers` runtime
|
||||
helpers, the `Compiler` (form → JVM bytecode), persistent data structures,
|
||||
`Var`/`Namespace`, the reader. `clojure/core.clj` is the language, in Clojure.
|
||||
Seam: ~20 primitive special forms + `RT` static methods. Everything else is
|
||||
Clojure.
|
||||
- **ClojureScript (self-hosted).** Two portable passes — `cljs.analyzer`
|
||||
(form → AST **as data**, reading a **compiler-state map** of
|
||||
namespaces/defs/macros, *not* host objects) and `cljs.compiler` (AST → JS, the
|
||||
host-specific back end). `cljs.core` is Clojure compiled to JS. Platform splits
|
||||
live in `.cljc` reader conditionals. This is the closest model to what we want:
|
||||
**the analyzer is host-agnostic; only the back end and the runtime are
|
||||
host-specific.**
|
||||
- **Nanopass / Guile Tree-IL.** A high-level IR is the portability seam; multiple
|
||||
back ends consume it.
|
||||
- **ClojureCLR / ClojureDart / jank.** Same shape every time: portable analyzer +
|
||||
host back end + host runtime.
|
||||
|
||||
The invariant across all of them: **the IR (analyzer output) and a small runtime
|
||||
protocol are the contract; the front end is portable, the back end and runtime
|
||||
are per-host.**
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
- **Seam = a minimal host protocol.** `jolt-core` calls a small documented set of
|
||||
host fns (in ns `jolt.host`): `resolve-sym`, `macro?`, `macroexpand-1`,
|
||||
`current-ns`, `intern!`, plus the `RT` primitives. Each host provides `jolt.host`
|
||||
(+ RT). Re-hosting = reimplement that handful of fns. The protocol *is* the
|
||||
boundary; `jolt-core` never touches Janet directly.
|
||||
- **Physical split now.** Portable Clojure lives under `jolt-core/` (a new source
|
||||
root, embedded into the binary like the rest of the stdlib); host Janet code for
|
||||
the new pipeline under `host/janet/`. Legacy host modules under `src/jolt/*.janet`
|
||||
are the existing Janet host and get relocated under `host/janet/` in a later
|
||||
mechanical pass (tracked) — not moved big-bang now, to keep the suite green.
|
||||
|
||||
## The Jolt split
|
||||
|
||||
```
|
||||
jolt-core/ PORTABLE Clojure — no Janet. Depends only on the contract.
|
||||
ir the IR spec (data shapes the analyzer emits)
|
||||
analyzer form -> IR (macroexpands; resolves via host protocol)
|
||||
macros when/cond/->/defn/... (the macro library, in Clojure)
|
||||
core clojure.core fns expressible in Clojure, over RT primitives
|
||||
|
||||
host/janet/ THE HOST — Janet. Implements the contract.
|
||||
reader text -> jolt forms
|
||||
rt data structures + RT primitive fns (cons/first/+/get/apply…)
|
||||
backend IR -> Janet forms -> Janet compile -> bytecode (the emitter)
|
||||
cenv the compile-time host protocol impl (resolve/macro?/intern)
|
||||
bootstrap load jolt-core, wire analyzer+backend into the loader
|
||||
interop janet.* bridge
|
||||
```
|
||||
|
||||
Two contracts cross the seam:
|
||||
|
||||
### 1. The IR (analyzer → back end)
|
||||
The existing `:op`-tagged AST, made **host-neutral**:
|
||||
- `{:op :const :val v}`, `:if`, `:do`, `:let`, `:fn` (arities), `:invoke`,
|
||||
`:vector`/`:map`/`:set`, `:quote`, `:throw`/`:try`, `:loop`/`:recur`.
|
||||
- **Globals reference vars by NAME, not by host cell:**
|
||||
`{:op :var :ns "clojure.core" :name "map"}`. (compiler.janet today embeds the
|
||||
Janet var cell as a constant — that's a host leak and breaks AOT. Name-based
|
||||
refs are both portable and AOT-friendly; the back end resolves the cell.)
|
||||
- No embedded host function values. Calls to runtime primitives are
|
||||
`{:op :rt :name "cons"}` resolved by the back end to the host's RT fn.
|
||||
|
||||
### 2. The host contract (two protocols)
|
||||
- **Compile-time (`cenv`)** — what the analyzer needs from the host while
|
||||
analyzing: `(current-ns)`, `(resolve-sym sym) -> {:kind :var|:macro|:local|:special|:host, :ns, :name}`,
|
||||
`(macroexpand-1 form)`, `(intern! ns sym meta)`. The analyzer calls only these;
|
||||
it never touches Janet ns/var tables. (CLJS keeps this as pure data; we use a
|
||||
small protocol — a minimal, documented boundary — because Jolt already has live
|
||||
ns/var objects. The protocol *is* the seam.)
|
||||
- **Runtime (`RT`)** — the primitive fns emitted code and `jolt-core` call by
|
||||
stable name: arithmetic/compare, `cons/first/rest/seq/conj/get/assoc/count`,
|
||||
`apply`, `=`, vector/map/set constructors, var deref/bind, keyword/symbol
|
||||
construction. The back end maps each to the host (on Janet, mostly the existing
|
||||
`core-*`). To re-host, implement this set.
|
||||
|
||||
## Why name-based vars (not embedded cells)
|
||||
|
||||
`compiler.janet` compiles a global ref to a closure over the Janet var cell. That
|
||||
(a) is a Janet value baked into the IR — not portable, and (b) can't be marshaled
|
||||
for AOT without the runtime-dict trick. Compiling instead to *resolve var by
|
||||
(ns,name) at call time* through an RT primitive keeps redefinition live, makes the
|
||||
IR host-neutral, and makes images trivially portable. The per-call lookup is the
|
||||
cost; it can be cached/direct-linked later as an opt-in optimization.
|
||||
|
||||
## Bootstrap & staging (keeps the suite green throughout)
|
||||
|
||||
`compiler.janet` stays as the **bootstrap back end** until the Clojure pipeline is
|
||||
proven. Order:
|
||||
|
||||
1. **Freeze the IR** spec and refactor `compiler.janet`'s emit to consume
|
||||
name-based `:var` (no behavior change; bootstrap still works).
|
||||
2. **Define the host contract** (`cenv` + `RT`) and implement it on Janet,
|
||||
exposed under a stable namespace the Clojure core can call.
|
||||
3. **Write `jolt.analyzer` in Clojure** producing IR, against `cenv`. Diff its IR
|
||||
against the Janet analyzer on the conformance corpus until identical.
|
||||
4. **Janet back end consumes IR** from the Clojure analyzer; wire into the loader
|
||||
behind a flag. Validate at parity (dual-mode conformance + clojure-test-suite).
|
||||
5. **Flip** the loader to the Clojure analyzer + Janet back end; `compiler.janet`
|
||||
shrinks to the back end only.
|
||||
6. **Move `clojure.core`** macros then fns into `jolt-core` incrementally, each
|
||||
compiled by the prior stage, isolating host bits behind `RT`.
|
||||
|
||||
Guards at every step: the dual-mode conformance harness (interpret vs compile)
|
||||
and the clojure-test-suite baseline.
|
||||
|
||||
## The portability test
|
||||
|
||||
When done, re-hosting Jolt to runtime X means writing only: `host/X/{reader, rt,
|
||||
backend, cenv, bootstrap}`. `jolt-core/{ir, analyzer, macros, core}` is reused
|
||||
unchanged. That is the concrete bar for "truly self-hosted and portable."
|
||||
170
doc/self-hosting-compiler.md
Normal file
170
doc/self-hosting-compiler.md
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
# Toward a self-hosting Jolt compiler
|
||||
|
||||
Research and design notes for evolving Jolt from "interpreter + opt-in ad-hoc
|
||||
compiler" toward a self-hosting Clojure-in-Clojure compiler that emits Janet
|
||||
bytecode, keeps full REPL live-redefinition, and rests on a minimal Janet
|
||||
bootstrap. This is a design doc, not a changelog — it describes where we are, the
|
||||
prior art, the constraints we verified, and a recommended path.
|
||||
|
||||
## The goal
|
||||
|
||||
- **Self-hosting, Clojure-in-Clojure.** A small kernel in the host (Janet) is
|
||||
enough to start; the rest of Clojure — including the compiler — is written in
|
||||
Clojure and compiled by Jolt itself, growing the language as it compiles more
|
||||
of itself.
|
||||
- **Janet bytecode out.** Compiled code runs as native Janet bytecode (fast),
|
||||
not tree-walking.
|
||||
- **Full runtime flexibility.** `def`/`defn` redefinition, vars, protocols,
|
||||
multimethods, and everything else stay live and redefinable at the REPL even
|
||||
for compiled code.
|
||||
- **Minimal host requirement.** Shrink what must exist in Janet to the
|
||||
irreducible base.
|
||||
|
||||
## Where Jolt is today
|
||||
|
||||
- ~5,500 lines of **Janet** implement `clojure.core` (`core.janet`) and a
|
||||
tree-walking interpreter (`evaluator.janet`); ~1k lines of **Clojure** are the
|
||||
stdlib (`clojure.string/set/walk/…`, `jolt.*`). So the language is mostly in
|
||||
the host, inverted from the Clojure-in-Clojure ideal.
|
||||
- The interpreter (`eval-form`) is the complete reference path.
|
||||
- The compiler (`compiler.janet`) — `analyze-form` (reader form → `:op` AST) →
|
||||
`emit` (AST → Janet form) → Janet `compile`/`eval` — is now **on by default**
|
||||
in the shipped runtime (`JOLT_INTERPRET=1` opts out). It is a *hybrid*: forms
|
||||
it can't compile correctly throw `jolt/uncompilable` and fall back to the
|
||||
interpreter (`loader/eval-toplevel`), so results always match the interpreter.
|
||||
Validated at parity — conformance 218/218 under both interpret and compile, and
|
||||
the clojure-test-suite under compile passes 3932 (vs the 3913 interpreter
|
||||
baseline) across ~4.6k assertions.
|
||||
- Done so far: var-indirection (globals deref through var cells, so compiled code
|
||||
is REPL-redefinable); hybrid fallback; compilation of multi-arity / named /
|
||||
variadic fns and `recur` inside `fn`; map and vector literal compilation
|
||||
(mode-correct via `make-vec` / `build-map-literal`); resolution that mirrors
|
||||
the interpreter (current ns → `clojure.core` → Janet-env fallback); and AOT
|
||||
(`aot.janet`) that marshals a compiled namespace to a Janet bytecode image
|
||||
against the baked-in runtime dictionary and loads it back.
|
||||
- Still open — the actual self-hosting: the compiler and most of `clojure.core`
|
||||
are still Janet. Rewriting them in Clojure (compiled by Jolt) is the remaining
|
||||
Clojure-in-Clojure work.
|
||||
|
||||
## What the host gives us (verified)
|
||||
|
||||
Janet already is the backend and the AOT story — we don't need a custom bytecode
|
||||
emitter:
|
||||
|
||||
- `(compile form env source)` → a **function** (compiled bytecode). Jolt's job is
|
||||
Clojure form → correct Janet form → `compile`.
|
||||
- `marshal`/`unmarshal`, `make-image`/`load-image` → serialize a compiled
|
||||
environment to a **bytecode image** and load it back: this is Phase 4 AOT.
|
||||
- `asm`/`disasm` → bytecode assembler/disassembler if we ever want to bypass the
|
||||
form layer (we shouldn't need to).
|
||||
|
||||
**The catch we verified:** Janet *early-binds* top-level references. Compile
|
||||
`(defn caller [] foo)`, then redefine `foo` — the compiled `caller` still returns
|
||||
the old value. So emitting Jolt globals as plain Janet symbols (what the current
|
||||
compiler largely does) is fundamentally incompatible with REPL redefinition. This
|
||||
is the single most important design constraint below.
|
||||
|
||||
## Prior art
|
||||
|
||||
- **Clojure (JVM).** A Java runtime + compiler bootstraps `clojure.core`, which is
|
||||
written in Clojure; thereafter Clojure compiles Clojure to JVM bytecode. Only
|
||||
~20 special forms are primitive; everything else is macros/functions. Crucially,
|
||||
compiled call sites go **through Var objects** (a deref), so redefining a var is
|
||||
visible to existing compiled callers — that's how speed and live redefinition
|
||||
coexist. Clojure 1.8 added opt-in **direct linking** (inline the call, drop the
|
||||
var indirection) for speed where you don't need redefinition (used for core in
|
||||
production). AOT compiles namespaces to `.class` files.
|
||||
- **ClojureScript self-hosting.** Two stages: an **analyzer** (source → AST plus
|
||||
a "compiler state" map of namespaces/defs/macros) and a **compiler** (AST → JS).
|
||||
`cljs.js` exposes compile/eval at runtime; bootstrapped CLJS compiles CLJS at
|
||||
~2× the JVM compiler. The host VM (JS engine) is the backend — the same shape we
|
||||
want with Janet as the backend.
|
||||
- **Nanopass (Chez Scheme).** A compiler as *many small passes* over *formally
|
||||
specified* intermediate languages, with autogenerated boilerplate to recur
|
||||
through unchanged forms and checks that each pass's output matches its grammar.
|
||||
The lesson for "grow the language as it compiles itself": keep passes small and
|
||||
IRs explicit so adding a form is local and verifiable.
|
||||
- **Guile.** A Lisp on a bytecode VM: source → Tree-IL (high-level IR) → CPS
|
||||
(optimization IR) → VM bytecode, with several front-end languages targeting
|
||||
Tree-IL. The closest analog to "Lisp → bytecode on a VM."
|
||||
|
||||
## Assessment: is the current approach the right one?
|
||||
|
||||
The overall *shape* is right and matches ClojureScript: front-end (analyze →
|
||||
emit) with the host VM as the backend, emitting host forms that the host compiles
|
||||
to bytecode. Two things need to change to reach the goal:
|
||||
|
||||
1. **Late binding for globals.** Compile a reference to a Jolt var as a **deref
|
||||
through the var cell**, not as a Janet symbol. Jolt vars are already cells
|
||||
(`{:jolt/type :jolt/var :root …}`); a compiled global call becomes roughly
|
||||
`((var-root cell) args…)` instead of `(janet-symbol args…)`. Redefinition
|
||||
updates the cell's root, so compiled callers see it — exactly Clojure's model.
|
||||
One indirection per global call; locals and control flow stay direct and fast.
|
||||
Offer opt-in **direct linking** for hot/AOT code that doesn't need redefinition.
|
||||
2. **Move the compiler and core into Clojure.** Today both are Janet. Self-hosting
|
||||
means the compiler is Clojure compiled by Jolt, and most of `clojure.core` is
|
||||
Clojure. That's the bulk of the work and where the "language builds itself"
|
||||
payoff lives.
|
||||
|
||||
So: keep the emit-to-Janet target (it's correct and gives us bytecode + AOT for
|
||||
free), fix global binding, and progressively self-host.
|
||||
|
||||
## Recommended architecture
|
||||
|
||||
**Pipeline (nanopass-lite).** Keep the data-driven `:op` AST and grow it as small,
|
||||
named passes rather than one big walker:
|
||||
|
||||
1. *read* — reader → forms (already have it).
|
||||
2. *macroexpand* — fully expand to special forms + calls (the interpreter already
|
||||
expands; share one expander).
|
||||
3. *analyze* — forms → AST, resolving locals vs vars and tagging ops.
|
||||
4. *(optional) optimize* — constant-fold, direct-link hot calls, etc.
|
||||
5. *emit* — AST → Janet form, with globals as var-cell derefs.
|
||||
6. *compile* — Janet `compile` → bytecode; `make-image` for AOT.
|
||||
|
||||
Make each pass total over the IR so an unhandled node is an explicit gap, not a
|
||||
silent miss.
|
||||
|
||||
**The kernel (minimal Janet bootstrap).** The irreducible base that must exist in
|
||||
the host before any Clojure can run: the reader; the value/representation layer
|
||||
(vars, namespaces, symbols, keywords, persistent collections, chars); host
|
||||
interop (the `janet.*` bridge); `fn`/`if`/`do`/`let`/`quote`/`def`/`loop`/`recur`
|
||||
evaluation; and `compile`/`eval`. Everything else — the rest of `clojure.core`,
|
||||
the macros, and the compiler — is Clojure loaded and (eventually) compiled by the
|
||||
kernel. Today the kernel is far larger than this; shrinking it is a long game.
|
||||
|
||||
**Hybrid interpret/compile (Phase 3, and a bootstrap safety net).** When a pass
|
||||
can't yet compile a sub-form, emit a call back into the interpreter (`eval-form`)
|
||||
for that sub-form instead of erroring. This lets the compiler be incomplete and
|
||||
still correct (hot paths compile, cold/unsupported paths interpret), lets us grow
|
||||
coverage incrementally, and de-risks the self-hosting bootstrap.
|
||||
|
||||
**Live flexibility.** Vars stay first-class cells; compiled code derefs them;
|
||||
`def` updates the root; protocol/multimethod dispatch stays dynamic. Direct
|
||||
linking is opt-in, never the default, so the REPL is always live.
|
||||
|
||||
## A staged path
|
||||
|
||||
1. **Var-indirection in the emitter** — *done*. Global refs compile as var-cell
|
||||
derefs, so a compiled `defn` is redefinable at the REPL.
|
||||
2. **Hybrid fallback + coverage** (`jolt-1bj`) — *done*. Forms the compiler can't
|
||||
compile throw `jolt/uncompilable` and fall back to the interpreter, so compile
|
||||
mode is always correct. Covered: multi-arity/named/variadic fns, `recur` in
|
||||
`fn`, map/vector literals, and resolution matching the interpreter. (One
|
||||
optimization left: compile destructuring via a shared `destructure` expander
|
||||
instead of falling back — `jolt-7dl`.)
|
||||
5. **Compile-by-default + AOT** (`jolt-7j9`) — *done, done out of order*. Once the
|
||||
hybrid path was validated at parity, compilation was flipped on by default and
|
||||
AOT images (`aot.janet`) landed. Done before 3–4 because it's the runtime
|
||||
payoff and only needed the hybrid path to be correct, not self-hosting.
|
||||
3. **Self-host the compiler** (`jolt-lcn`) — *open*. Rewrite `compiler.janet` as
|
||||
Clojure (`jolt.compiler`) that Jolt compiles. Now the compiler is part of the
|
||||
language it compiles.
|
||||
4. **Shrink the kernel / core-in-Clojure** (`jolt-uqi`) — *open*. Move
|
||||
`clojure.core` from Janet to Clojure incrementally, each piece compiled by the
|
||||
previous stage — the language building itself — leaving a minimal Janet kernel.
|
||||
|
||||
What remains (3 and 4) is the actual Clojure-in-Clojure rewrite: the largest part
|
||||
of the work and where the "language builds itself" payoff lives. The correctness
|
||||
and runtime foundations it needs — redefinable compiled code, an always-correct
|
||||
hybrid path, compile-by-default, and AOT — are now in place.
|
||||
Loading…
Add table
Add a link
Reference in a new issue