errors: load errors carry file:line and the require chain (jolt-2o7.4)

A failing top-level form now reports where it lives:

    Error: Cannot add 1 and "boom" — + expects numbers
      at /path/src/app/broken.clj:3
      while loading /path/src/app/mid.clj
      while loading /path/src/app/top.clj

The reader has no per-form positions (round 5), but the loaders know
exactly which slice of source each form came from: parse-all-positioned
(reader) counts newlines around parse-next and returns [form line]
pairs; load-ns, load-string and load-ns-source evaluate through a
positioned loop that on error stashes the innermost form's {:file
:line} on the env and appends each file unwound through to a loading
chain. report-error prints both, suppressing the synthetic <eval>
strings the CLI feeds itself (the require/apply one-liners).

load-string takes an optional file arg; run-file passes the script
path so script errors name the script. cli-test rows cover the
3-requires-deep case, script files, and that one-line -e output stays
clean. Gate green, suite 4718 steady, bench within noise.
This commit is contained in:
Yogthos 2026-06-12 10:40:42 -04:00
parent 32f733b6f6
commit 6d082e9b1d
6 changed files with 133 additions and 43 deletions

View file

@ -324,13 +324,7 @@
"Evaluate all forms from a Clojure source string. "Evaluate all forms from a Clojure source string.
Uses parse-next to load every top-level form in sequence. Uses parse-next to load every top-level form in sequence.
Returns the result of the last form evaluated." Returns the result of the last form evaluated."
[ctx s] [ctx s &opt file]
(var cur s) (default file "<eval>")
(var result nil) (eval-forms-positioned ctx (parse-all-positioned s) file))
(while (> (length (string/trim cur)) 0)
(def [form rest] (parse-next cur))
(set cur rest)
(when (not (nil? form))
(set result (eval-one ctx form))))
result)

View file

@ -358,14 +358,23 @@
(the :toplevel-eval hook) so REQUIRED namespaces compile like everything (the :toplevel-eval hook) so REQUIRED namespaces compile like everything
else — without it they ran interpreted-only: slower, and their fns were else — without it they ran interpreted-only: slower, and their fns were
anonymous closures in stack traces (jolt-2o7.1)." anonymous closures in stack traces (jolt-2o7.1)."
[ctx src] [ctx src &opt file]
(default file "<source>")
(def toplevel (get (ctx :env) :toplevel-eval)) (def toplevel (get (ctx :env) :toplevel-eval))
(var s src) (each [f line] (parse-all-positioned src)
(while (> (length (string/trim s)) 0) (try
(def [f r] (parse-next s)) (if toplevel (toplevel ctx f) (eval-form ctx @{} f))
(set s r) ([err fib]
(when (not (nil? f)) # innermost failing form wins; files unwound through form the
(if toplevel (toplevel ctx f) (eval-form ctx @{} f))))) # 'while loading …' chain (mirrors loader/eval-forms-positioned,
# which this can't import — circularity) (jolt-2o7.4)
(def env (ctx :env))
(when (nil? (get env :error-pos))
(put env :error-pos {:file file :line line}))
(when (nil? (get env :error-loading)) (put env :error-loading @[]))
(def chain (get env :error-loading))
(when (not= (last chain) file) (array/push chain file))
(propagate err fib)))))
(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
@ -395,7 +404,9 @@
# Stdlib files have no `ns` form, so switch into the target ns first # Stdlib files have no `ns` form, so switch into the target ns first
# (their defs intern there); a library's own `ns` form overrides this. # (their defs intern there); a library's own `ns` form overrides this.
(ctx-set-current-ns ctx ns-name) (ctx-set-current-ns ctx ns-name)
(if path (load-ns-source ctx (slurp path)) (load-ns-source ctx embedded)) (if path
(load-ns-source ctx (slurp path) path)
(load-ns-source ctx embedded (string ns-name " (stdlib)")))
# Record load order for tooling (uberscript): a dependency finishes # Record load order for tooling (uberscript): a dependency finishes
# loading before its requirer, so this is topological. Skip the # loading before its requirer, so this is topological. Skip the
# baked-in stdlib — it's part of the runtime, not something to bundle. # baked-in stdlib — it's part of the runtime, not something to bundle.

View file

@ -84,6 +84,27 @@
res) res)
(eval-toplevel-1 ctx form))) (eval-toplevel-1 ctx form)))
(defn eval-forms-positioned
"Evaluate parsed [form line] pairs, recording WHERE an error happened: the
innermost failing form's {:file :line} goes to (env :error-pos) and each
file unwound through joins the (env :error-loading) chain — the CLI's
report-error prints 'at file:line' and 'while loading …' from these.
(jolt-2o7.4)"
[ctx pairs file]
(var res nil)
(each [form line] pairs
(try
(set res (eval-toplevel ctx form))
([err fib]
(def env (ctx :env))
(when (nil? (get env :error-pos))
(put env :error-pos {:file file :line line}))
(when (nil? (get env :error-loading)) (put env :error-loading @[]))
(def chain (get env :error-loading))
(when (not= (last chain) file) (array/push chain file))
(propagate err fib))))
res)
(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-
interpret, stateful forms interpret) is shared with eval-one via eval-toplevel. interpret, stateful forms interpret) is shared with eval-one via eval-toplevel.
@ -91,16 +112,9 @@
(load-ns ctx filepath) → namespace symbol string" (load-ns ctx filepath) → namespace symbol string"
[ctx filepath] [ctx filepath]
(def source (slurp filepath)) (def source (slurp filepath))
(def pairs (parse-all-positioned source))
(var ns-name nil) (var ns-name nil)
(var remaining source) (each [form _] pairs
(var forms @[])
# Parse all forms
(while (> (length (string/trim remaining)) 0)
(def [form rest] (parse-next remaining))
(set remaining rest)
(when (not (nil? form))
(array/push forms form)
# Extract ns name from the first ns form # Extract ns name from the first ns form
(when (and (nil? ns-name) (when (and (nil? ns-name)
(array? form) (array? form)
@ -109,10 +123,10 @@
(= :symbol ((first form) :jolt/type)) (= :symbol ((first form) :jolt/type))
(= "ns" ((first form) :name)))) (= "ns" ((first form) :name))))
(let [name-form (in form 1)] (let [name-form (in form 1)]
(set ns-name (if (struct? name-form) (name-form :name) (string name-form))))))) (set ns-name (if (struct? name-form) (name-form :name) (string name-form))))))
(when (nil? ns-name) (when (nil? ns-name)
(error (string "No ns form found in " filepath))) (error (string "No ns form found in " filepath)))
(each form forms (eval-toplevel ctx form)) (eval-forms-positioned ctx pairs filepath)
ns-name) ns-name)

View file

@ -313,13 +313,28 @@
(defn- report-error [err fib] (defn- report-error [err fib]
(eprint "Error: " (rewrite-message (err-message err))) (eprint "Error: " (rewrite-message (err-message err)))
(def stashed (get (ctx :env) :error-trace)) (def env (ctx :env))
(put (ctx :env) :error-trace nil) (def stashed (get env :error-trace))
(def pos (get env :error-pos))
(def chain (get env :error-loading))
(put env :error-trace nil)
(put env :error-pos nil)
(put env :error-loading nil)
# <eval>:1 is the synthetic require/apply string the CLI feeds itself (and
# any one-line -e) — no information there
(when (and pos (not (and (= (pos :file) "<eval>") (= (pos :line) 1))))
(eprint " at " (pos :file) ":" (pos :line)))
(cond (cond
(os/getenv "JOLT_DEBUG") (os/getenv "JOLT_DEBUG")
(if stashed (eprin stashed) (when fib (debug/stacktrace fib ""))) (if stashed (eprin stashed) (when fib (debug/stacktrace fib "")))
stashed (print-user-trace stashed) stashed (print-user-trace stashed)
fib (when fib nil))) fib (when fib nil))
# requires unwound through, innermost first; the failing file itself is
# already on the 'at' line
(when chain
(each f chain
(unless (or (= f "<eval>") (and pos (= f (pos :file))))
(eprint " while loading " f)))))
(defn- run-repl [] (defn- run-repl []
(print "Jolt — Clojure on Janet") (print "Jolt — Clojure on Janet")
@ -360,7 +375,7 @@
(do (eprint "Error: file not found: " path) (os/exit 1)) (do (eprint "Error: file not found: " path) (os/exit 1))
(let [src (slurp path)] (let [src (slurp path)]
(try (try
(load-string ctx src) (load-string ctx src path)
([err fib] (report-error err fib) (os/exit 1)))))) ([err fib] (report-error err fib) (os/exit 1))))))
(defn- run-eval [expr argv] (defn- run-eval [expr argv]

View file

@ -651,3 +651,35 @@
(parse-next-loop new-pos) (parse-next-loop new-pos)
[form (string/slice s new-pos)]))) [form (string/slice s new-pos)])))
(parse-next-loop 0)) (parse-next-loop 0))
(defn parse-all-positioned
"Parse every top-level form of source, returning an array of [form line]
where line is the 1-based source line the form starts on. parse-next eats
leading trivia itself, so the form's start line is the running newline
count plus the newlines in the trivia (whitespace, commas, ; comments)
ahead of it. (jolt-2o7.4)"
[source]
(def out @[])
(var s source)
(var line 1)
(while (> (length (string/trim s)) 0)
# newlines in the leading trivia belong BEFORE the form's line
(var i 0)
(def n (length s))
(var scanning true)
(while (and scanning (< i n))
(def c (in s i))
(cond
(= c (chr "\n")) (do (++ line) (++ i))
(or (= c (chr " ")) (= c (chr "\t")) (= c (chr "\r")) (= c (chr ","))) (++ i)
(= c (chr ";")) (while (and (< i n) (not= (in s i) (chr "\n"))) (++ i))
(set scanning false)))
(def [form rest*] (parse-next s))
(def consumed (- (length s) (length rest*)))
(def form-line line)
# count newlines inside the consumed chunk past the trivia
(loop [j :range [i consumed]]
(when (= (in s j) (chr "\n")) (++ line)))
(set s rest*)
(when (not (nil? form)) (array/push out [form form-line])))
out)

View file

@ -37,9 +37,7 @@
(os/setenv "JOLT_PATH" nil) (os/setenv "JOLT_PATH" nil)
(os/rm (string tmp "/app/core.clj")) (os/rmdir (string tmp "/app")) (os/rmdir tmp) (os/rm (string tmp "/app/core.clj")) (os/rmdir (string tmp "/app")) (os/rmdir tmp)
(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) -------------------------- # --- user-facing error output (jolt-2o7 rounds 1+2) --------------------------
# Messages are Clojure-shaped; traces show the USER'S fns (compiled fn names # Messages are Clojure-shaped; traces show the USER'S fns (compiled fn names
@ -66,9 +64,35 @@
(check "no jolt-internal frames in user errors" (check "no jolt-internal frames in user errors"
(run-err "-e" `(+ 1 "a")`) (run-err "-e" `(+ 1 "a")`)
(fn [s] (nil? (string/find "src/jolt/" s)))) (fn [s] (nil? (string/find "src/jolt/" s))))
# --- round 4: load errors carry file:line + the require chain ---------------
(def r4 (string (or (os/getenv "TMPDIR") "/tmp") "/jolt-cli-r4-" (os/time)))
(os/mkdir r4) (os/mkdir (string r4 "/app"))
(spit (string r4 "/app/broken.clj") "(ns app.broken)\n\n(def config\n (+ 1 \"boom\"))\n")
(spit (string r4 "/app/mid.clj") "(ns app.mid (:require [app.broken :as b]))\n")
(spit (string r4 "/app/top.clj") "(ns app.top (:require [app.mid :as m]))\n(defn -main [& a] nil)\n")
(os/setenv "JOLT_PATH" r4)
(def deep-err (run-err "-m" "app.top"))
(check "load error names the failing file:line" deep-err
(has "at " ))
(check "load error points into broken.clj line 3" deep-err
(has "/app/broken.clj:3"))
(check "require chain shows the loading path" deep-err
(fn [s] (and (string/find "while loading" s) (string/find "/app/mid.clj" s) (string/find "/app/top.clj" s))))
(check "script errors name the script file"
(do (spit (string r4 "/scr.clj") "(ns scr)\n\n(+ 1 \"x\")\n")
(run-err (string r4 "/scr.clj")))
(has "/scr.clj:3"))
(check "no synthetic <eval> position on one-liners"
(run-err "-e" `(+ 1 "a")`)
(fn [s] (nil? (string/find "<eval>" s))))
(check "JOLT_DEBUG restores the raw trace" (check "JOLT_DEBUG restores the raw trace"
(do (os/setenv "JOLT_DEBUG" "1") (do (os/setenv "JOLT_DEBUG" "1")
(def r (run-err "-e" `(+ 1 "a")`)) (def r (run-err "-e" `(+ 1 "a")`))
(os/setenv "JOLT_DEBUG" nil) (os/setenv "JOLT_DEBUG" nil)
r) r)
(has "could not find method")) (has "could not find method"))
(if (> fails 0)
(error (string "cli-test: " fails " failing check(s)"))
(print "\nAll CLI tests passed!"))