From e0442f84e4b8c3f16c298be43a094bc57f6df07e Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 12 Jun 2026 08:58:08 -0400 Subject: [PATCH] errors: user-readable messages and stack traces (jolt-2o7 rounds 1+2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: (+ 1 "a") printed 'could not find method :+ for 1 or :r+ for "a"' followed by three janet frames pointing at jolt internals. After: Error: Cannot add 1 and "a" — + expects numbers at app.deep/level3 Round 1 — compiled fns carry their Clojure identity: - The analyzer's recur target (which doubles as the compiled janet fn's name) is now ns/fn-name (_r$app.deep/level3--N), so janet stack traces name the user's fns; defn passes the self-name through to fn. - eval-toplevel re-raises with propagate instead of protect+error — the failing fiber's stack was being discarded, which is why every trace began at eval-toplevel. - require/maybe-require-ns route loaded namespaces through the loader's compile-or-interpret eval-toplevel via a ctx hook (the evaluator can't import the loader). Previously REQUIRED namespaces always ran interpreted: slower, and their fns were anonymous in traces. Round 2 — report-error presents for users (rephrase-inspired): - The full trace text is stashed at the innermost eval-toplevel boundary (janet's debug/stacktrace walks the fiber propagation chain; debug/stack cannot), then filtered: _r$ frames demangled to ns/fn-name, jolt-internal and [eval] frames dropped. JOLT_DEBUG=1 restores the raw janet trace. - Message rewrites: janet arithmetic dispatch -> 'Cannot add X and Y — + expects numbers'; compiled arity -> Clojure's 'Wrong number of args (N) passed to: ns/fn'; nil-call gets an undefined-symbol hint (round 3 will fix resolution properly). 6 cli-test rows assert the exact user-visible output. Gate green, suite 4718 steady, bench within noise. --- jolt-core/clojure/core/00-syntax.clj | 4 +- jolt-core/jolt/analyzer.clj | 7 +- src/jolt/api.janet | 5 ++ src/jolt/evaluator.janet | 10 ++- src/jolt/loader.janet | 21 ++++-- src/jolt/main.janet | 96 +++++++++++++++++++++++++++- test/integration/cli-test.janet | 31 +++++++++ 7 files changed, 162 insertions(+), 12 deletions(-) diff --git a/jolt-core/clojure/core/00-syntax.clj b/jolt-core/clojure/core/00-syntax.clj index f3851df..a2ce11e 100644 --- a/jolt-core/clojure/core/00-syntax.clj +++ b/jolt-core/clojure/core/00-syntax.clj @@ -342,7 +342,9 @@ (let [body (if (and (seq body) (string? (first body))) (rest body) body) body (if (and (seq body) (map? (first body)) (not (symbol? (first body)))) (rest body) body)] - `(def ~fn-name (fn ~@body)))) + ;; pass the name through to fn: the compiled fn's janet name carries it, + ;; so stack traces read app.deep/level3 instead of a gensym (jolt-2o7.1) + `(def ~fn-name (fn ~fn-name ~@body)))) ;; Jolt doesn't enforce privacy, so defn- is just defn (matching how Clojure's own ;; defn- delegates to defn with :private metadata). diff --git a/jolt-core/jolt/analyzer.clj b/jolt-core/jolt/analyzer.clj index bf3e9a8..81459ac 100644 --- a/jolt-core/jolt/analyzer.clj +++ b/jolt-core/jolt/analyzer.clj @@ -81,7 +81,12 @@ ;; Always a recur target, variadic included: the back end gives the rest ;; param an ordinary positional slot (holding the collected seq), so recur ;; is a self-call carrying the rest seq directly — Clojure semantics. - rname (gen-name "arity") + ;; The recur target doubles as the COMPILED FN'S NAME, which is what a + ;; janet stack trace shows — so carry the Clojure ns/fn-name (jolt-2o7.1): + ;; an error inside app.deep/level3 traces as _r$app.deep/level3--N + ;; (report-error demangles the _r$/--N wrapper). gen-name's counter + ;; keeps recur targets unique per compilation unit. + rname (gen-name (str (compile-ns ctx) "/" (or fn-name "fn") "--")) names (cond-> (vec fixed) rst (conj rst) fn-name (conj fn-name)) env* (-> (add-locals env names) (with-recur rname)) arity {:params fixed :recur-name rname diff --git a/src/jolt/api.janet b/src/jolt/api.janet index 08851da..7683991 100644 --- a/src/jolt/api.janet +++ b/src/jolt/api.janet @@ -154,6 +154,11 @@ (install-async! ctx) # Host contract (ns jolt.host): the seam the portable jolt-core compiler calls. (host/install! ctx) + # require/maybe-require-ns route loaded namespaces through the loader's + # compile-or-interpret eval-toplevel (the evaluator can't import the loader + # — that would be circular — so it reads this hook). Without it, required + # namespaces ran interpreted-only. + (put (ctx :env) :toplevel-eval eval-toplevel) # Stateful primitives as ctx-capturing clojure.core fns (protocol-dispatch, # register-method, …) — so the protocol macros compile to plain invokes. Must # precede the overlay (its defprotocol/extend-type expansions call these). diff --git a/src/jolt/evaluator.janet b/src/jolt/evaluator.janet index 3e70f85..a25637c 100644 --- a/src/jolt/evaluator.janet +++ b/src/jolt/evaluator.janet @@ -353,13 +353,19 @@ found)) (defn- load-ns-source - "Parse and evaluate every form of a namespace's source in the given context." + "Parse and evaluate every form of a namespace's source in the given context. + Routes through the loader's eval-toplevel when the api has installed it + (the :toplevel-eval hook) so REQUIRED namespaces compile like everything + else — without it they ran interpreted-only: slower, and their fns were + anonymous closures in stack traces (jolt-2o7.1)." [ctx src] + (def toplevel (get (ctx :env) :toplevel-eval)) (var s src) (while (> (length (string/trim s)) 0) (def [f r] (parse-next s)) (set s r) - (when (not (nil? f)) (eval-form ctx @{} f)))) + (when (not (nil? f)) + (if toplevel (toplevel ctx f) (eval-form ctx @{} f))))) (defn- maybe-require-ns "If namespace ns-name isn't populated yet, load its source — from a file on the diff --git a/src/jolt/loader.janet b/src/jolt/loader.janet index 05486a4..8b51deb 100644 --- a/src/jolt/loader.janet +++ b/src/jolt/loader.janet @@ -52,11 +52,22 @@ (try-compile) (eval-form ctx @{} form))) (eval-form ctx @{} form))) - (def res (protect (run))) - (if (res 0) - (res 1) - (do (ctx-set-current-ns ctx entry-ns) - (error (res 1))))) + (try + (run) + ([err fib] + (ctx-set-current-ns ctx entry-ns) + # Stash the full trace TEXT at this innermost boundary: janet's + # debug/stacktrace walks the propagation chain (fiber->child, no public + # accessor), so this is the only place the user's compiled frames + # (in _r$ns/fn--N ...) are reachable. Innermost capture wins; the CLI's + # report-error filters + demangles it. (jolt-2o7.1/2) + (when (nil? (get (ctx :env) :error-trace)) + (def buf @"") + (with-dyns [:err buf] (debug/stacktrace fib err "")) + (put (ctx :env) :error-trace (string buf))) + # propagate (not error): re-raising with `error` discards the failing + # fiber's stack + (propagate err fib)))) (defn load-ns "Load a Clojure namespace from a .clj file. Per-form routing (compile-or- diff --git a/src/jolt/main.janet b/src/jolt/main.janet index ee71aaa..9ddbaa3 100644 --- a/src/jolt/main.janet +++ b/src/jolt/main.janet @@ -226,10 +226,100 @@ (string? err) err (string/format "%q" err))) +# --- error presentation (jolt-2o7.2, rephrase-inspired) ---------------------- +# Host messages rewritten into Clojure-shaped ones; stack frames filtered to +# the USER'S code (compiled jolt fns carry _r$ns/name--N janet names — round +# 2o7.1). JOLT_DEBUG=1 restores the raw janet trace for jolt development. + +(defn- demangle + "_r$app.deep/level3--105 -> app.deep/level3 (nil for non-jolt names)." + [nm] + (when (string/has-prefix? "_r$" nm) + (def s (string/slice nm 3)) + # the counter suffix is the LAST --N run + (var cut (length s)) + (var i (string/find "--" s)) + (while (not (nil? i)) + (set cut i) + (set i (string/find "--" s (+ i 1)))) + (string/slice s 0 cut))) + +(defn- fmt-val [x] + (cond + (string? x) (string `"` x `"`) + (nil? x) "nil" + (string x))) + +(def- op-words + {"+" "add" "-" "subtract" "*" "multiply" "/" "divide" + "<" "compare" ">" "compare" "<=" "compare" ">=" "compare"}) + +(defn- rewrite-message + "Host/janet error text -> a user-facing message. Unknown messages verbatim." + [msg] + (def msg (string msg)) + (cond + # janet polymorphic arithmetic: could not find method :+ for 1 or :r+ for "a" + (string/has-prefix? "could not find method :" msg) + (let [rest* (string/slice msg (length "could not find method :")) + sp (string/find " " rest*) + op (string/slice rest* 0 sp) + tail (string/slice rest* (+ sp (length " for "))) + orpos (string/find " or :r" tail) + a (string/slice tail 0 orpos) + forpos (string/find " for " tail (+ orpos 1)) + b (string/slice tail (+ forpos 5))] + (string "Cannot " (get op-words op op) " " a " and " b + " — " op " expects numbers")) + # janet fixed-arity: called with 2 arguments, expected 1 + (and (string/has-prefix? " called with " msg)) + (let [nm-end (string/find ">" msg) + nm (string/slice msg (length " called with "))) + n-end (string/find " " tail) + n (string/slice tail 0 n-end) + exp (if-let [i (string/find "expected " tail)] + (string " (expected " (string/slice tail (+ i 9)) ")") "")] + (string "Wrong number of args (" n ") passed to: " pretty exp)) + # a typo'd symbol compiles to nil today (round 2o7.3 will fix resolution) + (= msg "Cannot call nil as a function") + (string msg " — often an undefined (misspelled?) symbol") + msg)) + +(defn- print-user-trace + "Filter the stashed janet trace down to frames a jolt user can act on: + compiled jolt fns (the _r$ns/name--N janet names, demangled) and frames + from non-jolt source files. Internal janet/jolt frames are dropped." + [trace] + (var shown 0) + (each line (string/split "\n" trace) + (def t (string/trim line)) + (when (string/has-prefix? "in " t) + (def rest* (string/slice t 3)) + (def sp (or (string/find " " rest*) (length rest*))) + (def nm (string/slice rest* 0 sp)) + (cond + (string/has-prefix? "_r$" nm) + (do (eprint " at " (demangle nm)) (++ shown)) + # a frame from a real source file outside jolt internals + (and (string/find "[" rest*) + (not (string/find "src/jolt/" rest*)) + (not (string/find "boot.janet" rest*)) + (not (string/find "[eval]" rest*))) + (do (eprint " " t) (++ shown)) + nil))) + shown) + (defn- report-error [err fib] - (eprint "Error: " (err-message err)) - # Janet-level stack trace of where evaluation failed - (when fib (debug/stacktrace fib ""))) + (eprint "Error: " (rewrite-message (err-message err))) + (def stashed (get (ctx :env) :error-trace)) + (put (ctx :env) :error-trace nil) + (cond + (os/getenv "JOLT_DEBUG") + (if stashed (eprin stashed) (when fib (debug/stacktrace fib ""))) + stashed (print-user-trace stashed) + fib (when fib nil))) (defn- run-repl [] (print "Jolt — Clojure on Janet") diff --git a/test/integration/cli-test.janet b/test/integration/cli-test.janet index 11f7632..422efea 100644 --- a/test/integration/cli-test.janet +++ b/test/integration/cli-test.janet @@ -7,6 +7,12 @@ (os/proc-wait p) (string (or out ""))) +(defn- run-err [& args] + (def p (os/spawn ["janet" "src/jolt/main.janet" ;args] :p {:out :pipe :err :pipe})) + (def err (:read (p :err) :all)) + (os/proc-wait p) + (string (or err ""))) + (var fails 0) (defn check [label got pred] (if (pred got) (print " ok " label) @@ -34,3 +40,28 @@ (if (> fails 0) (error (string "cli-test: " fails " failing check(s)")) (print "\nAll CLI tests passed!")) + +# --- user-facing error output (jolt-2o7 rounds 1+2) -------------------------- +# Messages are Clojure-shaped; traces show the USER'S fns (compiled fn names +# carry ns/fn-name, demangled by report-error) and never jolt internals. +(check "arith error message rewritten" + (run-err "-e" `(+ 1 "a")`) + (has `Cannot add 1 and "a"`)) +(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" + (run-err "-e" "(undefined-fn 1)") + (has "undefined (misspelled?) symbol")) +(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)))) +(check "no jolt-internal frames in user errors" + (run-err "-e" `(+ 1 "a")`) + (fn [s] (nil? (string/find "src/jolt/" s)))) +(check "JOLT_DEBUG restores the raw trace" + (do (os/setenv "JOLT_DEBUG" "1") + (def r (run-err "-e" `(+ 1 "a")`)) + (os/setenv "JOLT_DEBUG" nil) + r) + (has "could not find method"))