From c230e70ed741029b1998721eb7bfb9d2885adbab Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 12 Jun 2026 10:07:48 -0400 Subject: [PATCH] errors: unresolved symbols error with Clojure's message (jolt-2o7.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A typo'd symbol used to auto-intern an unbound var and die later as 'Cannot call nil as a function' with no hint which symbol. Now: $ jolt -e '(undefined-fn 1)' Error: Unable to resolve symbol: undefined-fn in this context The analyzer's :unresolved fallthrough now punts to the interpreter (whose resolver raises the message above when the form runs) instead of emitting a var-ref that interned the var. A punt rather than a hard throw because runtime-interning forms (defmulti's setup) legitimately reference the var they're about to create from a nested do. Pulling that thread surfaced three real bugs the leniency was masking: - h-resolve-global resolved unqualified symbols against ctx-current-ns, which during analysis is jolt.analyzer — so user-ns vars NEVER resolved through it; the lenient arm happened to emit the right ns. Now resolves against the compile ns like the qualified branch. - Top-level (do ...) wasn't split: Clojure compiles and EVALS each child in sequence so earlier children's runtime effects (defmulti's intern) are visible while later children compile. eval-toplevel now splits. - The stdlib itself had forward references the auto-intern hid: 10-seq's transducers used vreset!/vswap! from 20-coll (moved to 10-seq); in 20-coll qualified-ident?/realized?/list*/underive referenced defs declared later in the file (reordered); sorted? and partition-all are genuinely later-tier and got (declare ...). Test rows updated where they encoded the old leniency: ir-passes' dead-branch row (unresolved in a dead branch is an error, as in Clojure), compile-mode's ctx-isolation row (other ctx now errors instead of reading nil), cli rows assert the new message. Gate green, conformance 335/335 x3, suite 4718 steady, bench within noise. --- jolt-core/clojure/core/10-seq.clj | 8 +++ jolt-core/clojure/core/20-coll.clj | 87 +++++++++++++----------- jolt-core/jolt/analyzer.clj | 12 +++- src/jolt/evaluator.janet | 2 +- src/jolt/host_iface.janet | 25 ++++--- src/jolt/loader.janet | 27 ++++++-- test/integration/cli-test.janet | 11 ++- test/integration/compile-mode-test.janet | 9 ++- test/integration/ir-passes-test.janet | 16 +++-- 9 files changed, 133 insertions(+), 64 deletions(-) diff --git a/jolt-core/clojure/core/10-seq.clj b/jolt-core/clojure/core/10-seq.clj index 9418b07..03c7177 100644 --- a/jolt-core/clojure/core/10-seq.clj +++ b/jolt-core/clojure/core/10-seq.clj @@ -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))) diff --git a/jolt-core/clojure/core/20-coll.clj b/jolt-core/clojure/core/20-coll.clj index 915c4ba..a12835e 100644 --- a/jolt-core/clojure/core/20-coll.clj +++ b/jolt-core/clojure/core/20-coll.clj @@ -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)) diff --git a/jolt-core/jolt/analyzer.clj b/jolt-core/jolt/analyzer.clj index 81459ac..8840292 100644 --- a/jolt-core/jolt/analyzer.clj +++ b/jolt-core/jolt/analyzer.clj @@ -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))] diff --git a/src/jolt/evaluator.janet b/src/jolt/evaluator.janet index a25637c..34bfbf5 100644 --- a/src/jolt/evaluator.janet +++ b/src/jolt/evaluator.janet @@ -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...]}" diff --git a/src/jolt/host_iface.janet b/src/jolt/host_iface.janet index 5fb787e..2a0981b 100644 --- a/src/jolt/host_iface.janet +++ b/src/jolt/host_iface.janet @@ -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) diff --git a/src/jolt/loader.janet b/src/jolt/loader.janet index 8b51deb..fa3ea3d 100644 --- a/src/jolt/loader.janet +++ b/src/jolt/loader.janet @@ -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. diff --git a/test/integration/cli-test.janet b/test/integration/cli-test.janet index 422efea..8e23317 100644 --- a/test/integration/cli-test.janet +++ b/test/integration/cli-test.janet @@ -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)))) diff --git a/test/integration/compile-mode-test.janet b/test/integration/compile-mode-test.janet index b794fad..35cfdaf 100644 --- a/test/integration/compile-mode-test.janet +++ b/test/integration/compile-mode-test.janet @@ -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})] diff --git a/test/integration/ir-passes-test.janet b/test/integration/ir-passes-test.janet index 77373c5..e819f87 100644 --- a/test/integration/ir-passes-test.janet +++ b/test/integration/ir-passes-test.janet @@ -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")