Merge pull request #88 from jolt-lang/error-round-3

errors: unresolved symbols error with Clojure's message (round 3)
This commit is contained in:
Dmitri Sotnikov 2026-06-12 14:18:28 +00:00 committed by GitHub
commit 32f733b6f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 133 additions and 64 deletions

View file

@ -8,6 +8,14 @@
;; self-hosted compiler (jolt-core/jolt/*.clj). Compiler-facing structural fns go
;; in the kernel tier (00-kernel) instead — see its header.
;; Volatiles (moved up from 20-coll: this tier's transducers use them, and the
;; analyzer now ERRORS on unresolved forward references — jolt-2o7.3). The
;; constructor (volatile!) stays native; these are pure over ref-put!/get.
(defn vreset! [vol newval]
(jolt.host/ref-put! vol :val newval) newval)
(defn vswap! [vol f & args]
(vreset! vol (apply f (get vol :val) args)))
(defn ffirst [coll] (first (first coll)))
(defn nfirst [coll] (next (first coll)))
(defn fnext [coll] (first (next coll)))

View file

@ -7,6 +7,8 @@
;; internal Janet callers, not used by the self-hosted compiler.
;; Tiny leaves first — fns below in this tier (and 25-sorted) use them.
(defn some? [x] (not (nil? x)))
(defn identity [x] x)
(defn constantly [x] (fn [& args] x))
@ -130,6 +132,11 @@
(defn split-with [pred coll] [(take-while pred coll) (drop-while pred coll)])
(defn qualified-keyword? [x] (and (keyword? x) (some? (namespace x))))
(defn simple-keyword? [x] (and (keyword? x) (nil? (namespace x))))
(defn qualified-symbol? [x] (and (symbol? x) (some? (namespace x))))
(defn simple-symbol? [x] (and (symbol? x) (nil? (namespace x))))
(defn ident? [x] (or (keyword? x) (symbol? x)))
(defn qualified-ident? [x] (or (qualified-symbol? x) (qualified-keyword? x)))
@ -234,10 +241,8 @@
(defn thread-bound? [& vars]
(every? (fn [v] (__thread-bound? v)) vars))
;; file-seq: the tree of paths under root (root included), directories walked
;; via the host dir primitives. Paths (strings), not File objects.
(defn file-seq [root]
(tree-seq __dir? __list-dir root))
(defn key [e] (if (map-entry? e) (nth e 0) (throw (ex-info "key requires a map entry" {}))))
(defn val [e] (if (map-entry? e) (nth e 1) (throw (ex-info "val requires a map entry" {}))))
;; --- Ad-hoc hierarchies (stage 3) — Clojure's canonical pure-map port. -----
;; A hierarchy is {:parents {tag #{parents}} :ancestors {tag #{all}}
@ -322,6 +327,10 @@
(defn counted? [x]
(or (vector? x) (map? x) (set? x) (list? x) (string? x)))
(defn indexed? [x] (vector? x))
;; sorted? is defined by the next tier (25-sorted) — declared here so this
;; tier compiles (forward references are analysis errors now, jolt-2o7.3).
(declare sorted?)
(defn reversible? [x] (or (vector? x) (sorted? x)))
(defn seqable? [x]
(or (nil? x) (coll? x) (string? x)))
@ -331,10 +340,8 @@
(defn float? [x] (double? x))
(defn infinite? [x] (and (number? x) (or (= x ##Inf) (= x ##-Inf))))
(defn qualified-keyword? [x] (and (keyword? x) (some? (namespace x))))
(defn simple-keyword? [x] (and (keyword? x) (nil? (namespace x))))
(defn qualified-symbol? [x] (and (symbol? x) (some? (namespace x))))
(defn simple-symbol? [x] (and (symbol? x) (nil? (namespace x))))
;; qualified-/simple- keyword?/symbol? moved above qualified-ident? (forward
;; references are analysis errors now — jolt-2o7.3).
;; find: the map entry [k v] when k is present (nil values included), nil
;; otherwise. contains? gives vectors-by-index for free, matching Clojure.
@ -342,6 +349,19 @@
(when (contains? m k) [k (get m k)]))
;; realized?: defined on the pending types only (delay/lazy-seq/future read
;; Tagged-value predicates. The constructors (atom/volatile!/...) stay in Janet,
;; but every tagged value carries its kind under :jolt/type (records under
;; :jolt/deftype), reachable via get — which is nil on non-tables — so the
;; predicates are pure over get and move out of the seed.
(defn atom? [x] (= (get x :jolt/type) :jolt/atom))
(defn volatile? [x] (= (get x :jolt/type) :jolt/volatile))
(defn reader-conditional? [x] (= (get x :jolt/type) :jolt/reader-conditional))
(defn tagged-literal? [x] (= (get x :jolt/type) :jolt/tagged-literal))
(defn record? [x] (some? (get x :jolt/deftype)))
(defn uuid? [x] (= (get x :jolt/type) :jolt/uuid))
(defn inst? [x] (= (get x :jolt/type) :jolt/inst))
(defn char? [x] (= (get x :jolt/type) :jolt/char))
;; their realization slot; promises/atoms always-realized), error otherwise.
(defn realized? [x]
(cond
@ -379,6 +399,14 @@
([coll] (dorun coll) coll)
([n coll] (dorun n coll) coll))
;; spread: (spread [1 2 [3 4]]) => (1 2 3 4) — list*'s variadic helper
;; (private in Clojure).
(defn- spread [arglist]
(cond
(nil? arglist) nil
(nil? (next arglist)) (seq (first arglist))
:else (cons (first arglist) (spread (next arglist)))))
;; list*: cons the leading args onto the final seq argument.
(defn list*
([args] (seq args))
@ -388,14 +416,6 @@
([a b c d & more]
(cons a (cons b (cons c (cons d (spread more)))))))
;; spread: (spread [1 2 [3 4]]) => (1 2 3 4) — list*'s variadic helper
;; (private in Clojure; defined after use is fine, vars resolve at call time).
(defn- spread [arglist]
(cond
(nil? arglist) nil
(nil? (next arglist)) (seq (first arglist))
:else (cons (first arglist) (spread (next arglist)))))
;; print-str family: print/println/prn into a captured *out*.
(defn print-str [& xs] (__with-out-str (fn* [] (apply print xs))))
(defn println-str [& xs] (__with-out-str (fn* [] (apply println xs))))
@ -495,6 +515,12 @@
(mapcat walk (children node))))))]
(walk root)))
;; file-seq: the tree of paths under root (root included), directories walked
;; via the host dir primitives. Paths (strings), not File objects. (Lives below
;; tree-seq: forward references are analysis errors now — jolt-2o7.3.)
(defn file-seq [root]
(tree-seq __dir? __list-dir root))
;; Canonical flatten via tree-seq: the leaves (non-sequential nodes) in order.
;; Flattens lists too (sequential?), matching Clojure/CLJS.
(defn flatten [coll]
@ -603,19 +629,6 @@
(defn ex-cause [e]
(let [e (ex-unwrap e)] (if (ex-info-val? e) (get e :cause) nil)))
;; Tagged-value predicates. The constructors (atom/volatile!/...) stay in Janet,
;; but every tagged value carries its kind under :jolt/type (records under
;; :jolt/deftype), reachable via get — which is nil on non-tables — so the
;; predicates are pure over get and move out of the seed.
(defn atom? [x] (= (get x :jolt/type) :jolt/atom))
(defn volatile? [x] (= (get x :jolt/type) :jolt/volatile))
(defn reader-conditional? [x] (= (get x :jolt/type) :jolt/reader-conditional))
(defn tagged-literal? [x] (= (get x :jolt/type) :jolt/tagged-literal))
(defn record? [x] (some? (get x :jolt/deftype)))
(defn uuid? [x] (= (get x :jolt/type) :jolt/uuid))
(defn inst? [x] (= (get x :jolt/type) :jolt/inst))
(defn char? [x] (= (get x :jolt/type) :jolt/char))
;; inst-ms: epoch milliseconds of an instant; throws on a non-inst (Clojure
;; protocol behavior).
(defn inst-ms [x]
@ -635,6 +648,10 @@
([n step coll] (map vec (partition n step coll)))
([n step pad coll] (map vec (partition n step pad coll))))
;; partition-all is a lazy-tier fn (40-lazy) — declared so partitionv-all
;; compiles; bound by the time anything calls it.
(declare partition-all)
(defn partitionv-all
([n coll] (map vec (partition-all n coll)))
([n step coll] (map vec (partition-all n step coll))))
@ -684,12 +701,7 @@
(defn set-validator! [a f]
(jolt.host/ref-put! a :validator f) nil)
;; Volatiles. The constructor (volatile!) stays native — it builds the mutable box —
;; but vreset! sets the box's slot through ref-put! and vswap! is pure over it + get.
(defn vreset! [vol newval]
(jolt.host/ref-put! vol :val newval) newval)
(defn vswap! [vol f & args]
(vreset! vol (apply f (get vol :val) args)))
;; vreset!/vswap! live in the seq tier (10-seq.clj): its transducers use them.
;; Future status predicates — pure reads of the future's :cached/:cancelled slots.
;; future? stays native (deref/future-cancel/realized? call it); future-call and
@ -761,8 +773,7 @@
;; Strict, as in Clojure: an entry is what (seq m) yields (a host tuple), NOT
;; a plain vector — (key [1 2]) throws.
(defn key [e] (if (map-entry? e) (nth e 0) (throw (ex-info "key requires a map entry" {}))))
(defn val [e] (if (map-entry? e) (nth e 1) (throw (ex-info "val requires a map entry" {}))))
;; key/val moved above the hierarchies section (underive uses them).
;; find was previously missing from jolt entirely. Presence (contains?), not
;; value, decides — so (find {:a nil} :a) is [:a nil]. Works on vectors by
@ -772,7 +783,7 @@
(defn find [m k]
(when (contains? m k) (first {k (get m k)})))
(defn some? [x] (not (nil? x)))
;; some? lives in the top leaf block now (forward refs are errors).
(defn true? [x] (= true x))
(defn false? [x] (= false x))

View file

@ -199,7 +199,17 @@
(case (:kind r)
:var (var-ref (:ns r) (:name r))
:host (host-ref (:name r))
(var-ref (compile-ns ctx) nm))))))
;; :unresolved — previously emitted a var-ref that auto-interned
;; an UNBOUND var, so a typo'd symbol died later as 'Cannot call
;; nil as a function' with no hint which symbol (jolt-2o7.3).
;; Punt to the interpreter: its resolver raises Clojure's
;; 'Unable to resolve symbol' when the form actually runs (at
;; eval for top-level forms, at call for fn bodies). A punt
;; rather than a hard throw because runtime-interning forms
;; (defmulti's setup call) legitimately reference the var they
;; are about to create when nested in a non-top-level do. Real
;; forward references want (declare ...), as in Clojure.
(uncompilable (str "Unable to resolve symbol: " nm " in this context")))))))
(defn- analyze-list [ctx form env]
(let [items (vec (form-elements form))]

View file

@ -740,7 +740,7 @@
# No implicit Janet fallback (Stage 3): an unresolved
# Clojure symbol is an error. Host access is the explicit
# janet/ prefix above.
(error (string "Unable to resolve symbol: " name))))))))))))))))))
(error (string "Unable to resolve symbol: " name " in this context"))))))))))))))))))
(defn- parse-arg-names
"Parse a parameter vector, handling & rest args.
Returns {:fixed [names...] :rest name-or-nil :all [names...]}"

View file

@ -130,14 +130,23 @@
# 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})))))
# Unqualified syms must resolve against the COMPILE ns (h-current-ns), not
# ctx-current-ns: the analyzer runs interpreted in jolt.analyzer, so during
# analysis resolve-var's unqualified branch looks in the wrong namespace.
# Before jolt-2o7.3 the analyzer's lenient fallthrough masked this (it
# emitted a var-ref against compile-ns for everything 'unresolved').
(def v (if (sym :ns)
(resolve-var ctx @{} sym)
(let [cns (ctx-find-ns ctx (h-current-ns ctx))]
(or (and cns (ns-find cns (sym :name)))
(ns-find (ctx-find-ns ctx "clojure.core") (sym :name))))))
(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)

View file

@ -24,12 +24,7 @@
(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 runs through the self-hosted compile pipeline
(portable Clojure analyzer -> IR -> Janet back end), which falls back to the
interpreter for forms it can't compile. Only the compile step is guarded —
runtime errors in compiled code propagate (no double-eval, no hidden errors)."
(defn- eval-toplevel-1
[ctx form]
# Repair point for the interpreted-fn ns swap: a body runs with current-ns
# rebound to its defining ns and restores it on normal return; an UNWINDING
@ -69,6 +64,26 @@
# fiber's stack
(propagate err fib))))
(defn eval-toplevel
"Evaluate one top-level form for ctx, honoring :compile?. Stateful forms always
interpret; otherwise the form runs through the self-hosted compile pipeline
(portable Clojure analyzer -> IR -> Janet back end), which falls back to the
interpreter for forms it can't compile. Only the compile step is guarded —
runtime errors in compiled code propagate (no double-eval, no hidden errors)."
[ctx form]
# Clojure's top-level `do` rule: children are compiled AND evaluated one at
# a time, so a child's runtime effects (defmulti's var intern, requires, …)
# are visible while the NEXT child compiles. Without the split, (do
# (defmulti area …) (area …)) can't analyze — `area` only exists once the
# defmulti has RUN, and unresolved symbols are analysis errors now
# (jolt-2o7.3).
(if (and (array? form) (= "do" (form-head-name form)))
(do
(var res nil)
(each child (array/slice form 1) (set res (eval-toplevel ctx child)))
res)
(eval-toplevel-1 ctx form)))
(defn load-ns
"Load a Clojure namespace from a .clj file. Per-form routing (compile-or-
interpret, stateful forms interpret) is shared with eval-one via eval-toplevel.

View file

@ -50,9 +50,16 @@
(check "arity error names the fn"
(run-err "-e" "(defn afn [x] x) (afn 1 2)")
(has "Wrong number of args (2) passed to: user/afn"))
(check "nil-call hints at undefined symbol"
(check "nil-call (nil value) keeps the hint"
(run-err "-e" "(def x nil) (x 1)")
(has "Cannot call nil as a function"))
# round 3: typos die at resolve time with Clojure's message, not as nil-calls
(check "unresolved symbol named at resolve time"
(run-err "-e" "(undefined-fn 1)")
(has "undefined (misspelled?) symbol"))
(has "Unable to resolve symbol: undefined-fn in this context"))
(check "typo inside fn body also resolves to the message"
(run-err "-e" "(defn f [] (no-such 1)) (f)")
(has "Unable to resolve symbol: no-such"))
(check "trace shows the user's call chain"
(run-err "-e" "(defn inner [x] (let [r (+ x :k)] r)) (defn outer [x] (let [v (inner x)] v)) (outer 1)")
(fn [s] (and (string/find "at user/inner" s) (string/find "at user/outer" s))))

View file

@ -124,12 +124,15 @@
"runtime error in compiled code propagates"))
# 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.
# var-indirection each context has its own var cells, so in b `secret` does not
# resolve at all ('Unable to resolve symbol', jolt-2o7.3) rather than seeing a's 7.
(let [a (init-cached {:compile? true}) b (init-cached {:compile? true})]
(eval-string a "(def secret 7)")
(assert (= 7 (ct-eval a "secret")) "def visible in its own ctx")
(assert (nil? (ct-eval b "secret")) "def isolated to its ctx"))
(def r (protect (ct-eval b "secret")))
(assert (and (not (r 0))
(string/find "Unable to resolve symbol: secret" (string/format "%q" (r 1))))
"def isolated to its ctx (unresolved there)"))
# Redefinition is visible to already-compiled callers (var-indirection).
(let [c (init-cached {:compile? true})]

View file

@ -20,12 +20,18 @@
(check-const "(quot 7 2)" 3)
(check-const "(mod -7 3)" 2)
(check-const "(if (< 1 2) :yes :no)" :yes)
# dead-branch elimination: the untaken branch never evaluates
(check-const "(if false (this-would-not-resolve) 2)" 2)
# dead-branch elimination: the untaken branch never evaluates (it must still
# RESOLVE — unresolved symbols are analysis errors as in Clojure, jolt-2o7.3)
(check-const "(if false (throw (ex-info \"boom\" {})) 2)" 2)
# an unresolvable symbol errors even in a dead branch (analysis precedes folding)
(assert (not ((protect (ir "(if false (this-would-not-resolve) 2)")) 0))
"unresolved symbol errors even in a dead branch")
# non-constants stay calls; folding must be conservative
(assert (= :invoke ((ir "(+ x 2)") :op)) "free var stays a call")
(assert (= :invoke ((ir "(mod x 0)") :op)) "non-const args stay calls")
# non-constants stay calls; folding must be conservative. `xq` is a real var
# (defined here) so it resolves, but its VALUE must not be folded in.
(api/eval-string ctx "(def xq 1)")
(assert (= :invoke ((ir "(+ xq 2)") :op)) "var ref stays a call")
(assert (= :invoke ((ir "(mod xq 0)") :op)) "non-const args stay calls")
# a fold that would THROW is left for runtime
(assert (= :invoke ((ir "(mod 5 0)") :op)) "throwing fold left to runtime")