Merge pull request #87 from jolt-lang/error-rounds-1-2

errors: user-readable messages and stack traces (rounds 1+2)
This commit is contained in:
Dmitri Sotnikov 2026-06-12 13:07:46 +00:00 committed by GitHub
commit 3fd0af33b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 162 additions and 12 deletions

View file

@ -342,7 +342,9 @@
(let [body (if (and (seq body) (string? (first body))) (rest body) body) (let [body (if (and (seq body) (string? (first body))) (rest body) body)
body (if (and (seq body) (map? (first body)) (not (symbol? (first body)))) body (if (and (seq body) (map? (first body)) (not (symbol? (first body))))
(rest body) 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 ;; Jolt doesn't enforce privacy, so defn- is just defn (matching how Clojure's own
;; defn- delegates to defn with :private metadata). ;; defn- delegates to defn with :private metadata).

View file

@ -81,7 +81,12 @@
;; Always a recur target, variadic included: the back end gives the rest ;; Always a recur target, variadic included: the back end gives the rest
;; param an ordinary positional slot (holding the collected seq), so recur ;; param an ordinary positional slot (holding the collected seq), so recur
;; is a self-call carrying the rest seq directly — Clojure semantics. ;; 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)) names (cond-> (vec fixed) rst (conj rst) fn-name (conj fn-name))
env* (-> (add-locals env names) (with-recur rname)) env* (-> (add-locals env names) (with-recur rname))
arity {:params fixed :recur-name rname arity {:params fixed :recur-name rname

View file

@ -154,6 +154,11 @@
(install-async! ctx) (install-async! ctx)
# Host contract (ns jolt.host): the seam the portable jolt-core compiler calls. # Host contract (ns jolt.host): the seam the portable jolt-core compiler calls.
(host/install! ctx) (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, # Stateful primitives as ctx-capturing clojure.core fns (protocol-dispatch,
# register-method, …) — so the protocol macros compile to plain invokes. Must # register-method, …) — so the protocol macros compile to plain invokes. Must
# precede the overlay (its defprotocol/extend-type expansions call these). # precede the overlay (its defprotocol/extend-type expansions call these).

View file

@ -353,13 +353,19 @@
found)) found))
(defn- load-ns-source (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] [ctx src]
(def toplevel (get (ctx :env) :toplevel-eval))
(var s src) (var s src)
(while (> (length (string/trim s)) 0) (while (> (length (string/trim s)) 0)
(def [f r] (parse-next s)) (def [f r] (parse-next s))
(set s r) (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 (defn- maybe-require-ns
"If namespace ns-name isn't populated yet, load its source — from a file on the "If namespace ns-name isn't populated yet, load its source — from a file on the

View file

@ -52,11 +52,22 @@
(try-compile) (try-compile)
(eval-form ctx @{} form))) (eval-form ctx @{} form)))
(eval-form ctx @{} form))) (eval-form ctx @{} form)))
(def res (protect (run))) (try
(if (res 0) (run)
(res 1) ([err fib]
(do (ctx-set-current-ns ctx entry-ns) (ctx-set-current-ns ctx entry-ns)
(error (res 1))))) # 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 (defn load-ns
"Load a Clojure namespace from a .clj file. Per-form routing (compile-or- "Load a Clojure namespace from a .clj file. Per-form routing (compile-or-

View file

@ -226,10 +226,100 @@
(string? err) err (string? err) err
(string/format "%q" 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: <function _r$ns/f--N> called with 2 arguments, expected 1
(and (string/has-prefix? "<function " msg) (string/find "> called with " msg))
(let [nm-end (string/find ">" msg)
nm (string/slice msg (length "<function ") nm-end)
pretty (or (demangle nm) nm)
tail (string/slice msg (+ nm-end (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] (defn- report-error [err fib]
(eprint "Error: " (err-message err)) (eprint "Error: " (rewrite-message (err-message err)))
# Janet-level stack trace of where evaluation failed (def stashed (get (ctx :env) :error-trace))
(when fib (debug/stacktrace fib ""))) (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 [] (defn- run-repl []
(print "Jolt — Clojure on Janet") (print "Jolt — Clojure on Janet")

View file

@ -7,6 +7,12 @@
(os/proc-wait p) (os/proc-wait p)
(string (or out ""))) (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) (var fails 0)
(defn check [label got pred] (defn check [label got pred]
(if (pred got) (print " ok " label) (if (pred got) (print " ok " label)
@ -34,3 +40,28 @@
(if (> fails 0) (if (> fails 0)
(error (string "cli-test: " fails " failing check(s)")) (error (string "cli-test: " fails " failing check(s)"))
(print "\nAll CLI tests passed!")) (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"))