loader: share compile/interpret routing between load-ns and eval-one

Moves the per-form routing (compile when :compile?, stateful forms always
interpret, interpreter fallback for uncompilable forms) into loader as
eval-toplevel, used by both load-ns and api/eval-one. Previously load-ns's
compile path called compile-and-eval directly with no fallback and no
stateful-form handling, so it would have broken ns/require/defmacro under
compile-by-default.

Array forms whose head is not a symbol (e.g. ((fn ...) args)) compile like
any other call, matching the prior eval-one behavior.
This commit is contained in:
Yogthos 2026-06-06 02:19:21 -04:00
parent 2872f17c59
commit acf724b638
2 changed files with 56 additions and 53 deletions

View file

@ -55,44 +55,12 @@
(install-async! ctx)
ctx))
# Stateful / context-modifying forms always use the interpreter (they mutate
# the context: namespaces, macros, types, multimethods, dynamic vars, …).
(defn- stateful-head? [head-name]
(or (= head-name "defmacro") (= head-name "ns")
(= head-name "deftype") (= head-name "defmulti") (= head-name "defmethod")
(= head-name "require") (= head-name "in-ns")
(= head-name "syntax-quote") (= head-name "set!")
(= head-name "var") (= head-name ".") (= head-name "new")
(= head-name "eval")))
# Hybrid compile path: try to compile the form, fall back to the interpreter for
# anything the compiler can't (yet) handle. Only the compile step is guarded —
# runtime errors in compiled code propagate, so we never double-eval side effects
# or hide real errors behind a fallback. The interpreter handles every form, so
# the result is always correct; compilation is a transparent speedup.
(defn- try-compile-eval [ctx form]
(let [compiled (protect (compile-ast form ctx))]
(if (compiled 0)
(eval-compiled (compiled 1) ctx)
(eval-form ctx @{} form))))
(defn eval-one
"Evaluate a single already-parsed form, routing to the compiler when the
context has :compile? enabled (stateful forms always interpret; forms the
compiler can't handle fall back to the interpreter)."
"Evaluate a single already-parsed form. Routing (compile when :compile? is set,
stateful forms interpret, interpreter fallback for forms the compiler can't
handle) lives in loader/eval-toplevel so load-ns and eval-one stay in sync."
[ctx form]
(if (get (ctx :env) :compile?)
(if (array? form)
(let [first-form (first form)
head-name (if (and (struct? first-form) (= :symbol (first-form :jolt/type)))
(first-form :name) nil)]
(if (stateful-head? head-name)
(eval-form ctx @{} form)
(try-compile-eval ctx form)))
(if (or (and (struct? form) (= :symbol (form :jolt/type))) (tuple? form))
(try-compile-eval ctx form)
(eval-form ctx @{} form)))
(eval-form ctx @{} form)))
(eval-toplevel ctx form))
(defn eval-string
"Evaluate a Clojure source string in a Jolt context.

View file

@ -6,6 +6,47 @@
(use ./compiler)
(use ./evaluator)
# Stateful / context-modifying forms always interpret: they mutate the context
# (namespaces, macros, types, multimethods, dynamic vars, …) in ways the compiler
# doesn't model. Kept here so the compile/interpret routing lives in one place,
# used by both load-ns and the public eval-one.
(defn- stateful-head? [head-name]
(or (= head-name "defmacro") (= head-name "ns")
(= head-name "deftype") (= head-name "defmulti") (= head-name "defmethod")
(= head-name "require") (= head-name "in-ns")
(= head-name "syntax-quote") (= head-name "set!")
(= head-name "var") (= head-name ".") (= head-name "new")
(= head-name "eval")))
(defn- form-head-name [form]
(when (array? form)
(let [ff (first form)]
(when (and (struct? ff) (= :symbol (ff :jolt/type))) (ff :name)))))
(defn eval-toplevel
"Evaluate one top-level form for ctx, honoring :compile?. Stateful forms always
interpret; otherwise the form is compiled and run, falling back to the
interpreter when the compiler can't handle it. Only the compile step is guarded
— runtime errors in compiled code propagate (no double-eval, no hidden errors)."
[ctx form]
(defn try-compile []
(let [compiled (protect (compile-ast form ctx))]
(if (compiled 0)
(eval-compiled (compiled 1) ctx)
(eval-form ctx @{} form))))
(if (get (ctx :env) :compile?)
(if (array? form)
# A call/list: compile it unless its head is a stateful special form.
(let [hn (form-head-name form)]
(if (and hn (stateful-head? hn))
(eval-form ctx @{} form)
(try-compile)))
# A bare symbol or vector literal compiles; anything else interprets.
(if (or (and (struct? form) (= :symbol (form :jolt/type))) (tuple? form))
(try-compile)
(eval-form ctx @{} form)))
(eval-form ctx @{} form)))
(defn load-ns
"Load a Clojure namespace from a .clj file.
When ctx has :compile? enabled, forms are compiled to Janet source,
@ -41,23 +82,17 @@
(when (nil? ns-name)
(error (string "No ns form found in " filepath)))
(if compile?
(do
# Compile each form and eval as Janet
(var cached (get cache ns-name))
(when (nil? cached)
(set cached @[])
(put cache ns-name cached))
(each form forms
(array/push cached form)
(compile-and-eval form ctx))
ns-name)
# Interpreter path
(do
(each form forms
(eval-form ctx @{} form))
ns-name))))
# Per-form routing (compile-or-interpret, stateful forms interpret) is shared
# with eval-one via eval-toplevel. When compiling, also record the forms so a
# namespace can be inspected / re-emitted.
(when compile?
(var cached (get cache ns-name))
(when (nil? cached)
(set cached @[])
(put cache ns-name cached))
(each form forms (array/push cached form)))
(each form forms (eval-toplevel ctx form))
ns-name))
(defn compiled?
"Check if a namespace has been compiled and cached."