self-host: working portable analyzer -> IR -> Janet back end pipeline

The Clojure-in-Clojure front end now compiles and runs a real subset end to end:
arithmetic, if/do/let, vector/map literals, def + name-based global refs, fn,
defn (via macroexpand), recursion through the var cell, multi-arity, variadic,
and higher-order calls. Proven by test/integration/self-host-test.janet.

Pieces:
- jolt-core/jolt/analyzer.clj — PORTABLE Clojure analyzer: reader form -> IR,
  depending only on the host contract (jolt.host), never on Janet.
- src/jolt/host_iface.janet — Janet impl of the jolt.host contract (form
  introspection + resolve/macro/current-ns), installed into each ctx.
- src/jolt/backend.janet — Janet back end: IR -> Janet form -> eval; resolves
  name-based :var nodes to cells and reuses runtime helpers.

Host Janet code lives in src/jolt/ (not a host/janet/ dir): Janet resolves
relative imports per file, so cross-dir ../../src/jolt/* imports load second
instances of compiler/types/core and corrupt state. The portability boundary is
the jolt.host namespace contract + jolt-core/, not the directory.

Notes: gensym in the back end must not be Janet's (shadowed by Jolt's via use);
a jolt bug — (into #{} coll) doesn't add elements — was worked around with reduce
conj in the analyzer (filed separately).
This commit is contained in:
Yogthos 2026-06-06 06:11:42 -04:00
parent b1ac427bdd
commit 849449aada
6 changed files with 438 additions and 0 deletions

View file

@ -11,6 +11,7 @@
(use ./loader)
(use ./async)
(import ./stdlib_embed :as stdlib-embed)
(import ./host_iface :as host)
(defn normalize-pvecs
"Deep-convert any sequential (pvec/tuple/array) to a Janet tuple. Test helper
@ -53,6 +54,8 @@
# clojure.core.async (channels + go blocks on Janet fibers); pre-populated
# so (require '[clojure.core.async ...]) finds it and applies :as/:refer.
(install-async! ctx)
# Host contract (ns jolt.host): the seam the portable jolt-core compiler calls.
(host/install! ctx)
ctx))
(defn eval-one

141
src/jolt/backend.janet Normal file
View file

@ -0,0 +1,141 @@
# Janet back end: host-neutral IR (from jolt.analyzer) -> Janet form -> bytecode.
#
# Host-specific by definition (it targets Janet). It resolves name-based :var
# nodes to Janet var cells and reuses runtime helpers (jolt-call, make-vec,
# build-map-literal). The portable front end (jolt.analyzer) never sees any of
# this; a different runtime provides its own back end against the same IR.
#
# In src/jolt/ (not host/janet/) for the same module-resolution reason as
# host_iface — see that file's header.
(use ./types)
(use ./core)
(import ./compiler :as comp)
(use ./evaluator)
(import ./reader :as r)
# Var late-binding: deref/set through the cell via a memoized closure so compiled
# code sees redefinition (Janet early-binds plain symbols). Same scheme as the
# bootstrap compiler.
(defn- var-getter [cell]
(or (get cell :jolt/getter)
(let [g (fn [] (var-get cell))] (put cell :jolt/getter g) g)))
(defn- var-setter [cell]
(or (get cell :jolt/setter)
(let [s (fn [v] (bind-root cell v) cell)] (put cell :jolt/setter s) s)))
(defn- cell-for [ctx ns-name nm]
(ns-intern (ctx-find-ns ctx ns-name) nm))
# Fresh Janet symbol for back-end-introduced bindings (arity dispatch). NOT
# Janet's `gensym` — `(use ./core)` shadows it with Jolt's, which returns a jolt
# symbol struct (invalid in a Janet param position).
(var- gsym-counter 0)
(defn- gsym [] (def s (symbol "_be$" gsym-counter)) (++ gsym-counter) s)
(var emit nil)
(defn- emit-seq [ctx node]
(def out @['do])
(each s (vview (node :statements)) (array/push out (emit ctx s)))
(array/push out (emit ctx (node :ret)))
(tuple/slice out))
(defn- emit-let [ctx node]
(def binds @[])
(each pair (vview (node :bindings))
(def p (vview pair))
(array/push binds (symbol (in p 0)))
(array/push binds (emit ctx (in p 1))))
['let (tuple/slice binds) (emit ctx (node :body))])
(defn- emit-arity-fn [ctx ar]
(def ps @[])
(each pn (vview (ar :params)) (array/push ps (symbol pn)))
(when (ar :rest) (array/push ps '&) (array/push ps (symbol (ar :rest))))
['fn (tuple/slice ps) (emit ctx (ar :body))])
(defn- emit-fn [ctx node]
(def arities (vview (node :arities)))
(if (= 1 (length arities))
(emit-arity-fn ctx (first arities))
# Multi-arity: dispatch on arg count; fixed arities match exactly, the
# variadic one matches >= its fixed count. apply spreads the captured args
# into the chosen arity fn (whose own & collects any rest).
(let [jargs (gsym)
nsym (gsym)
cf @['cond]]
(each ar arities
(def nfixed (length (vview (ar :params))))
(array/push cf (if (ar :rest) [>= nsym nfixed] [= nsym nfixed]))
(array/push cf [apply (emit-arity-fn ctx ar) jargs]))
(array/push cf ['error "wrong number of args passed to fn"])
['fn ['& jargs]
['do ['def nsym ['length jargs]] (tuple/slice cf)]])))
(defn- direct-call? [fnode]
(case (fnode :op) :var true :local true :fn true :host true false))
(defn- emit-invoke [ctx node]
(def f (emit ctx (node :fn)))
(def args (map |(emit ctx $) (vview (node :args))))
(if (direct-call? (node :fn))
(tuple f ;args)
(tuple jolt-call f ;args)))
(defn- emit-vector [ctx node]
(def items (map |(emit ctx $) (vview (node :items))))
(tuple make-vec (tuple/slice (array/concat @['tuple] items))))
(defn- emit-map [ctx node]
(def args @[comp/build-map-literal])
(each pair (vview (node :pairs))
(def p (vview pair))
(array/push args (emit ctx (in p 0)))
(array/push args (emit ctx (in p 1))))
(tuple/slice args))
(set emit
(fn emit [ctx node]
(case (node :op)
:const (node :val)
:local (symbol (node :name))
:host (symbol (node :name))
:var (tuple (var-getter (cell-for ctx (node :ns) (node :name))))
:if ['if (emit ctx (node :test)) (emit ctx (node :then)) (emit ctx (node :else))]
:do (emit-seq ctx node)
:throw ['error (emit ctx (node :expr))]
:def (tuple (var-setter (cell-for ctx (node :ns) (node :name))) (emit ctx (node :init)))
:let (emit-let ctx node)
:fn (emit-fn ctx node)
:invoke (emit-invoke ctx node)
:vector (emit-vector ctx node)
:map (emit-map ctx node)
:quote ['quote (node :form)]
(error (string "backend: unhandled op " (node :op))))))
(defn emit-ir
"IR node -> Janet form (public entry for the back end)."
[ctx node]
(emit ctx node))
# --- pipeline wiring (the self-hosted compile path) ---
(defn- ensure-analyzer [ctx]
# Load jolt.analyzer (and transitively jolt.ir) once; jolt.host is pre-installed
# by host/install! so its require is a no-op.
(when (= 0 (length ((ctx-find-ns ctx "jolt.analyzer") :mappings)))
(eval-form ctx @{} (r/parse-string "(require '[jolt.analyzer])"))))
(defn analyze-form
"Run the portable Clojure analyzer (jolt.analyzer/analyze) on a reader form,
returning host-neutral IR."
[ctx form]
(ensure-analyzer ctx)
(def av (ns-find (ctx-find-ns ctx "jolt.analyzer") "analyze"))
((var-get av) ctx form))
(defn compile-and-eval
"Self-hosted compile path: analyze (portable Clojure) -> IR -> Janet -> eval."
[ctx form]
(eval (emit-ir ctx (analyze-form ctx form)) (comp/ctx-janet-env ctx)))

101
src/jolt/host_iface.janet Normal file
View file

@ -0,0 +1,101 @@
# Janet implementation of the Jolt host contract (ns `jolt.host`).
#
# This is the seam between the portable jolt-core (analyzer/IR/core, pure Clojure
# under jolt-core/) and the Janet runtime. jolt-core calls ONLY these functions —
# never Janet directly. Re-hosting Jolt to another runtime means reimplementing
# this contract (+ the back end and RT) for that runtime.
#
# Lives in src/jolt/ (with the rest of the Janet host) rather than a separate
# host/janet/ dir: Janet resolves relative imports per-file, so a host/janet
# module importing ../../src/jolt/* loads SECOND instances of compiler/types/core
# (inconsistent state). The portability boundary is the `jolt.host` namespace
# contract + jolt-core/, not the directory.
#
# Two groups:
# 1. Form introspection — reader forms are host-specific (the reader is the
# host's), so shape predicates/accessors live here. Returns jolt values the
# analyzer walks with ordinary Clojure.
# 2. Compile-time environment — resolve symbols to vars/macros, expand macros,
# the current namespace. These take ctx (an opaque host handle).
(use ./types)
(use ./evaluator)
(use ./core)
# ---------------------------------------------------------------------------
# Form introspection
# ---------------------------------------------------------------------------
(defn h-sym? [form] (and (struct? form) (= :symbol (form :jolt/type))))
(defn h-sym-name [form] (form :name))
(defn h-sym-ns [form] (form :ns))
(defn h-list? [form] (array? form)) # a call / list (reader: array)
(defn h-vector? [form] (tuple? form)) # a vector literal (reader: tuple)
(defn h-map? [form] (and (struct? form) (nil? (form :jolt/type))))
(defn h-set? [form] (and (struct? form) (= :jolt/set (form :jolt/type))))
(defn h-char? [form] (and (struct? form) (= :jolt/char (form :jolt/type))))
(defn h-literal? [form]
(or (nil? form) (boolean? form) (number? form) (string? form)
(keyword? form) (h-char? form)))
# Items of a list/vector as a jolt vector, so the analyzer walks them with Clojure.
(defn h-elements [form] (make-vec form))
(defn h-vector-items [form] (make-vec form))
(defn h-map-pairs [form]
(make-vec (map (fn [k] (make-vec [k (get form k)])) (keys form))))
(defn h-set-items [form] (make-vec (form :value)))
# ---------------------------------------------------------------------------
# Compile-time environment
# ---------------------------------------------------------------------------
(defn h-current-ns [ctx] (ctx-current-ns ctx))
(defn h-macro? [ctx sym]
(let [v (resolve-var ctx @{} sym)]
(if (and v (var-macro? v)) true false)))
(defn h-expand-1 [ctx form]
(let [head (in form 0)
v (resolve-var ctx @{} head)
macro-fn (var-get v)]
(apply macro-fn (tuple/slice form 1))))
# Classify a global (non-local) symbol reference:
# {:kind :var :ns NS :name NAME} — a Jolt var (current ns / clojure.core)
# {:kind :host :name NAME} — resolves only via the host env (+, int?, …),
# same fallback the interpreter's resolve-sym uses
# {:kind :unresolved :name NAME} — not yet defined (forward reference)
(defn h-resolve-global [ctx sym]
(let [v (resolve-var ctx @{} sym)]
(if v
{:kind :var :ns (var-ns v) :name (var-name v)}
(let [nm (sym :name)
entry (in (fiber/getenv (fiber/current)) (symbol nm))]
(if (not (nil? entry))
{:kind :host :name nm}
{:kind :unresolved :name nm})))))
(defn h-intern! [ctx ns-name nm]
(ns-intern (ctx-find-ns ctx ns-name) nm)
nil)
# ---------------------------------------------------------------------------
# Installation: bind these fns as vars in the `jolt.host` namespace so jolt-core
# can call them. Idempotent per context.
# ---------------------------------------------------------------------------
(def- exports
{"sym?" h-sym? "sym-name" h-sym-name "sym-ns" h-sym-ns
"list?" h-list? "vector?" h-vector? "map?" h-map? "set?" h-set? "char?" h-char?
"literal?" h-literal? "elements" h-elements "vector-items" h-vector-items
"map-pairs" h-map-pairs "set-items" h-set-items
"current-ns" h-current-ns "macro?" h-macro? "expand-1" h-expand-1
"resolve-global" h-resolve-global "intern!" h-intern!})
(defn install! [ctx]
(def ns (ctx-find-ns ctx "jolt.host"))
(eachp [nm f] exports (ns-intern ns nm f))
ns)