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)
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).

View file

@ -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

View file

@ -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).

View file

@ -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

View file

@ -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-

View file

@ -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: <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]
(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")

View file

@ -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"))