diff --git a/README.md b/README.md index fa4adbb..eeda385 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![tests](https://github.com/jolt-lang/jolt/actions/workflows/tests.yml/badge.svg)](https://github.com/jolt-lang/jolt/actions/workflows/tests.yml) -A Clojure interpreter running on [Janet](https://janet-lang.org). Jolt reads Clojure source, evaluates it with an interpreter written in pure Janet, and ships a Clojure-compatible standard library. The goal is a Janet-hosted [SCI](https://github.com/borkdude/sci) runtime — a minimal bootstrap that loads SCI's Clojure source as its standard library. +A Clojure implementation on [Janet](https://janet-lang.org). Jolt reads Clojure source and, by default, compiles each form to native Janet bytecode — falling back to a tree-walking interpreter for forms the compiler doesn't handle, so results always match the interpreter. It ships a Clojure-compatible standard library. The goal is a Janet-hosted [SCI](https://github.com/borkdude/sci)-style runtime with a minimal bootstrap. ## Build @@ -61,51 +61,48 @@ hello 42 ### Evaluation pipeline: interpreted and compiled -Every form Jolt evaluates passes through one router (`eval-one`), which decides -*per form* whether to tree-walk it or compile it to Janet. There are two modes: +Every form passes through one router (`loader/eval-toplevel`) that decides *per +form* whether to tree-walk it or compile it to Janet bytecode. The shipped +runtime **compiles by default**; set `JOLT_INTERPRET=1` to force the interpreter. -**Interpreted (default).** Without `:compile?`, every form is evaluated by the -tree-walking interpreter (`eval-form`). This is the live, fully-featured path: -all of Clojure's semantics — macros, multimethods, protocols, dynamic vars, -lazy seqs, destructuring — go through here. +**Hybrid, always correct.** The compiler is incomplete by design: a form it can't +compile correctly throws `jolt/uncompilable`, and the router falls back to the +tree-walking interpreter (`eval-form`) for that form. So the result *always* +matches the interpreter — compilation is a transparent speedup, never a semantic +change. Only the compile step is guarded; runtime errors in compiled code +propagate normally (no double-evaluation, no hidden errors). -**Compiled (`:compile? true`).** With compilation enabled, the router splits each -top-level form two ways: +What compiles: `def`/`defn`, multi-arity / named / variadic fns, `recur` (in +`loop` and directly in `fn`), `let`/`if`/`do`/`try`/`throw`/`quote`, map and +vector literals, and calls. What falls back to the interpreter: context-modifying +and definitional forms (`ns`, `defmacro`, `deftype`, `defprotocol`, +`defmulti`/`defmethod`, `reify`, `require`, `binding`, …), destructuring, regex +literals, and the handful of interpreter-only special forms. -- **Context-modifying forms always interpret.** `ns`, `defmacro`, `deftype`, - `defmulti`/`defmethod`, `require`, `in-ns`, `set!`, `var`, `.`, `new`, `eval`, - and syntax-quote mutate the evaluation context (namespaces, the macro table, - type/method registries, dynamic vars), so they are routed to the interpreter - unchanged. -- **Everything else compiles to Janet.** The form is macro-expanded, lowered to - a Janet AST, and `eval`'d in a **per-context Janet environment**. `def`/`defn` - bindings live in that environment so they persist and resolve across forms - (and self-recurse via a named-fn rewrite); hot numeric primitives - (`+ - * < > <= >=`) emit native Janet ops so the JIT-free Janet VM runs them at - full speed; and function calls compile to direct Janet calls (keyword/map/set - in call position still dispatch through the IFn runtime). - -The two paths **share one context.** Compiled `def`/`defn` results are both -evaluated into the Janet environment *and* interned into the Jolt namespace, so -an interpreted form can call a compiled function and vice-versa within the same -context — which is what makes the always-interpret carve-out above safe. +**Live redefinition.** Compiled global references deref through Jolt **var cells** +(Janet early-binds plain symbols, which would freeze redefinition), so redefining +a `def`/`defn` at the REPL is visible to already-compiled callers — Clojure's var +model. Hot numeric primitives (`+ - * < > <= >=`) emit native Janet ops, and +calls compile to direct Janet calls. ```janet (def ctx (init {:compile? true})) (eval-string ctx "(defn fib [n] (if (< n 2) n (+ (fib (- n 1)) (fib (- n 2)))))") -(eval-string ctx "(fib 30)") ; → 832040, fast +(eval-string ctx "(fib 30)") ; → 832040, native Janet bytecode ``` -For compute-heavy code the compiled path is dramatically faster — recursive -`fib(30)` runs in ~0.08 s compiled vs ~50 s interpreted (≈600×), at native Janet -speed. +For compute-heavy code the compiled path is dramatically faster than tree-walking, +at native Janet speed. -Compile mode is opt-in and still maturing. The numeric-op inlining relaxes the -strict non-number checks (e.g. `(< nil 1)` doesn't throw), and constructs the -compiler doesn't yet handle currently **error** rather than transparently -falling back to the interpreter — a per-form hybrid fallback (compile what we -can, interpret the rest) is the next step toward making compilation safe to -turn on by default. +**Validated at parity.** The conformance suite passes 218/218 under *both* +interpreter and compiler (`conformance-test.janet` runs both in CI), and the full +clojure-test-suite under compilation matches the interpreter baseline across +~4.6k assertions — evidence the hybrid path doesn't diverge. + +**AOT.** `aot.janet` marshals a compiled namespace to a Janet bytecode image +(`save-ns`) and loads it back into a fresh context (`load-ns-image`), skipping +parse/analyze/emit/compile on reload. Core fns are referenced by name against the +baked-in runtime; only user bytecode and var cells are serialized. ## Host interop diff --git a/doc/self-hosting-compiler.md b/doc/self-hosting-compiler.md index 72db447..05d9ada 100644 --- a/doc/self-hosting-compiler.md +++ b/doc/self-hosting-compiler.md @@ -26,12 +26,25 @@ prior art, the constraints we verified, and a recommended path. 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 live, complete path. -- There's an opt-in compiler (`compiler.janet`): `analyze-form` (reader form → - AST tagged with `:op`) → `emit` (AST → Janet form) → Janet `compile`/`eval`. - Phases 1–2 are done (per-context env so defs persist and resolve; native - arithmetic ops + direct calls — recursive `fib(30)` ≈ 0.08 s). Phase 3 - (destructuring, multi-arity, hybrid fallback) is open. +- 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) @@ -130,23 +143,28 @@ coverage incrementally, and de-risks the self-hosting bootstrap. `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 (maps onto the existing beads) +## A staged path -1. **Var-indirection in the emitter** *(new, foundational — do before more - compiler work)*. Compile global refs as var-cell derefs; verify a compiled - `defn` is redefinable at the REPL. Without this, more compiler coverage just - bakes in more early-binding to undo later. -2. **Hybrid fallback + finish coverage** (`jolt-1bj`, Phase 3): per-form fallback - to `eval-form`; then compile destructuring, multi-arity/variadic, and the - remaining forms as optimizations on top of the always-correct fallback. -3. **Self-host the compiler.** 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.** Move `clojure.core` from Janet to - Clojure incrementally, leaving only the minimal kernel in Janet. Each moved - piece is compiled by the previous stage — the language building itself. -5. **Compile-by-default + AOT** (`jolt-7j9`, Phase 4): once the hybrid path is - robust, flip compilation on by default; ship AOT images via `make-image`. +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. -The ordering matters: var-indirection first (correctness for redefinition), then -the hybrid fallback (correctness for coverage), then self-hosting and kernel -shrinking (the Clojure-in-Clojure payoff), then default-on + AOT. +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.