compiler phase 1: var-indirection for redefinable compiled globals

Compiled global references now deref through the Jolt var cell instead of an
early-bound Janet symbol, so redefining a def/defn is visible to already-compiled
callers (Janet early-binds plain symbols). A global ref analyzes to :op :var
(resolving/creating the cell); a def sets that same cell's root. Because Janet
copies table constants but references functions, we embed memoized getter/setter
closures over the cell rather than the cell itself. This also subsumes the old
named-fn recursion rewrite and the separate ns-intern.

ns-intern now stores the namespace *name* (string) in a var, not the ns table, so
var cells aren't cyclic (required for embedding).

fib(30) compiled ~0.5s (the late-binding cost vs phase 2's direct-linked 0.08s;
direct-linking for hot calls is a later optimization). Suite green; cross-form,
recursion, and context isolation preserved; redefinition now works.
This commit is contained in:
Yogthos 2026-06-06 01:33:47 -04:00
parent 614962d535
commit 70711b4489
3 changed files with 53 additions and 35 deletions

View file

@ -370,7 +370,13 @@
{:op :local :name name}
(if (and (not (special-form? name)) (get core-renames name))
{:op :core-symbol :name name :janet-name (get core-renames name)}
{:op :symbol :name name}))))
# A global reference: resolve (or create) the Jolt var cell now and
# compile a deref through it, so redefinition is visible to compiled
# callers (Janet early-binds plain symbols). No ctx -> plain symbol.
(if ctx
{:op :var :name name
:var (ns-intern (ctx-find-ns ctx (ctx-current-ns ctx)) name)}
{:op :symbol :name name})))))
(array? form)
(let [first-form (first form)
@ -435,9 +441,13 @@
:else (if (> (length form) 3)
(analyze-form (in form 3) bindings ctx)
{:op :const :val nil})}
"def" {:op :def
:name (in form 1)
:init (analyze-form (in form 2) bindings ctx)}
"def" (let [name-sym (in form 1)
nm (if (struct? name-sym) (name-sym :name) (string name-sym))
# Create/find the var cell first so a recursive init body
# self-references the same cell.
cell (when ctx (ns-intern (ctx-find-ns ctx (ctx-current-ns ctx)) nm))]
{:op :def :name name-sym :var cell
:init (analyze-form (in form 2) bindings ctx)})
"fn*" (let [params (in form 1)
body-bindings (do
(var bb @{})
@ -709,6 +719,7 @@
(match (ast :op)
:const (emit-const-str (ast :val) buf)
:symbol (emit-symbol-str (ast :name) buf)
:var (emit-symbol-str (ast :name) buf)
:local (emit-local-str (ast :name) buf)
:core-symbol (emit-core-symbol-str (ast :janet-name) buf)
:qualified-symbol (emit-qualified-symbol-str (ast :ns) (ast :name) buf)
@ -768,6 +779,19 @@
(defn- emit-def-expr [name-sym init]
['def (symbol (name-sym :name)) (emit-expr init)])
# Var-indirection: a global reference derefs its cell at call time, and a def
# sets the same cell's root and returns it (Clojure's #'var). Janet COPIES table
# constants when compiling but references functions, so we embed memoized
# getter/setter CLOSURES over the cell (by reference) rather than the cell itself.
(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- emit-var-expr [cell] (tuple (var-getter cell)))
(defn- emit-def-var-expr [cell init] (tuple (var-setter cell) (emit-expr init)))
(defn- emit-fn-expr [params body]
(def param-syms @[])
(each p params
@ -828,7 +852,7 @@
# only when the head is a keyword/collection literal in call position (an IFn
# that needs runtime lookup), e.g. (:k m) or ({:a 1} :a).
(def direct (case (f-ast :op)
:core-symbol true :symbol true :local true
:core-symbol true :symbol true :var true :local true
:qualified-symbol true :fn true
false))
(def f (emit-expr f-ast))
@ -854,12 +878,14 @@
(match (ast :op)
:const (emit-const-expr (ast :val))
:symbol (emit-symbol-expr (ast :name))
:var (emit-var-expr (ast :var))
:local (emit-local-expr (ast :name))
:core-symbol (emit-core-symbol-expr (ast :janet-name))
:qualified-symbol (emit-qualified-symbol-expr (ast :ns) (ast :name))
:do (emit-do-expr (ast :statements) (ast :ret))
:if (emit-if-expr (ast :test) (ast :then) (ast :else))
:def (emit-def-expr (ast :name) (ast :init))
:def (if (ast :var) (emit-def-var-expr (ast :var) (ast :init))
(emit-def-expr (ast :name) (ast :init)))
:fn (emit-fn-expr (ast :params) (ast :body))
:let (emit-let-expr (ast :binding-pairs) (ast :body))
:throw (emit-throw-expr (ast :val))
@ -893,30 +919,10 @@
(emit-expr (analyze-form form @{} ctx)))
(defn compile-and-eval
"Compile a Clojure form and evaluate it as Janet, in the context's persistent
Janet env (so compiled def/defn bindings resolve across forms). For def/defn
forms, also interns the result in the Jolt namespace so the interpreter can
resolve it later."
"Compile a Clojure form and evaluate it as Janet. Globals resolve through Jolt
var cells (see analyze-form/:var), so compiled def/defn results are visible to
the interpreter (the cell is the namespace var), recursion self-references the
cell, and redefinition is seen by compiled callers — no separate interning or
named-fn rewrite needed."
[form ctx]
(def env (ctx-janet-env ctx))
(def def-form? (and ctx (array? form) (> (length form) 0)
(struct? (first form)) (= :symbol ((first form) :jolt/type))
(let [h ((first form) :name)] (or (= h "def") (= h "defn") (= h "defn-")))))
(def def-name (when def-form?
(let [name-sym (in form 1)] (if (struct? name-sym) (name-sym :name) name-sym))))
(var compiled (compile-ast form ctx))
# Name the fn after the def so a recursive body self-references lexically
# ((def f (fn [..] (f ..))) -> (def f (fn f [..] (f ..)))); the anonymous form
# can't resolve f at compile time.
(when (and def-name (indexed? compiled) (= 3 (length compiled))
(= 'def (in compiled 0))
(indexed? (in compiled 2)) (= 'fn (in (in compiled 2) 0))
(indexed? (in (in compiled 2) 1))) # 3rd elem is (fn [params] ...)
(let [f (in compiled 2)]
(set compiled [(in compiled 0) (in compiled 1)
[(in f 0) (symbol def-name) ;(tuple/slice f 1)]])))
(def result (eval compiled env))
# Also intern def/defn results in the Jolt namespace for interpreter resolution.
(when def-name
(ns-intern (ctx-find-ns ctx (ctx-current-ns ctx)) def-name result))
result)
(eval (compile-ast form ctx) (ctx-janet-env ctx)))

View file

@ -297,7 +297,10 @@
(when (not (nil? val))
(bind-root existing val))
existing)
(let [v (make-var sym val {:ns ns :name sym})]
# Store the namespace *name*, not the ns table: a back-pointer to the ns
# would make the var cyclic (ns -> mappings -> var -> ns), and the compiler
# embeds var cells as constants, which can't be cyclic.
(let [v (make-var sym val {:ns (get ns :name) :name sym})]
(put mappings sym v)
v))))

View file

@ -100,10 +100,19 @@
(assert (= 2 (ct-eval ctx "(#{1 2 3} 2)")) "set as fn")
(assert (= true (ct-eval ctx "(= [1 2] [1 2])")) "= is value equality, not core-= bypass"))
# Context isolation: a def in one compiled context is invisible in another.
# Context isolation: a def in one compiled context is invisible in another. With
# var-indirection each context has its own var cells, so b's `secret` is a
# distinct, unbound var (nil) rather than a's 7.
(let [a (init {:compile? true}) b (init {:compile? true})]
(eval-string a "(def secret 7)")
(assert (= 7 (ct-eval a "secret")) "def visible in its own ctx")
(assert (not ((protect (ct-eval b "secret")) 0)) "def isolated to its ctx"))
(assert (nil? (ct-eval b "secret")) "def isolated to its ctx"))
# Redefinition is visible to already-compiled callers (var-indirection).
(let [c (init {:compile? true})]
(eval-string c "(defn g [] 1)")
(eval-string c "(defn calls-g [] (g))")
(eval-string c "(defn g [] 2)")
(assert (= 2 (ct-eval c "(calls-g)")) "compiled caller sees redefined global"))
(print "\nAll Phase 6 tests passed!")