Adds doc/self-hosting-architecture.md: the portability design (portable front end + host back end + host runtime, seam = a minimal host protocol + the IR), grounded in the CLJS/Clojure-in-Clojure prior art. Decisions locked: minimal host protocol as the seam; physical split with a jolt-core/ source root. Adds jolt-core/ as a portable Clojure source root (dev path + embedded into the binary alongside the rest of the stdlib) and jolt-core/jolt/ir.clj — host-neutral IR node constructors (vars referenced by name, no host values embedded). Verified it loads and runs under both interpreter and compiler.
7.3 KiB
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:
- Self-hosted — the compiler and most of
clojure.coreare written in Clojure and compiled by Jolt itself. - 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-coreis 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/Numbersruntime helpers, theCompiler(form → JVM bytecode), persistent data structures,Var/Namespace, the reader.clojure/core.cljis the language, in Clojure. Seam: ~20 primitive special forms +RTstatic 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) andcljs.compiler(AST → JS, the host-specific back end).cljs.coreis Clojure compiled to JS. Platform splits live in.cljcreader 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-corecalls a small documented set of host fns (in nsjolt.host):resolve-sym,macro?,macroexpand-1,current-ns,intern!, plus theRTprimitives. Each host providesjolt.host(+ RT). Re-hosting = reimplement that handful of fns. The protocol is the boundary;jolt-corenever 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 underhost/janet/. Legacy host modules undersrc/jolt/*.janetare the existing Janet host and get relocated underhost/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 andjolt-corecall 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 existingcore-*). 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:
- Freeze the IR spec and refactor
compiler.janet's emit to consume name-based:var(no behavior change; bootstrap still works). - Define the host contract (
cenv+RT) and implement it on Janet, exposed under a stable namespace the Clojure core can call. - Write
jolt.analyzerin Clojure producing IR, againstcenv. Diff its IR against the Janet analyzer on the conformance corpus until identical. - 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).
- Flip the loader to the Clojure analyzer + Janet back end;
compiler.janetshrinks to the back end only. - Move
clojure.coremacros then fns intojolt-coreincrementally, each compiled by the prior stage, isolating host bits behindRT.
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."