feat: precise file:line:col source locations for type errors (jolt-fqy)
RFC 0006 error reporting wanted file:line:col but IR nodes carried no
position, so diagnostics read only "type error in <ns>: <msg>". Now:
type error /tmp/scene.clj:5:5: `inc` requires a number, but argument 1 is a string
The reader records each LIST form's absolute start offset in a table keyed
by form identity (lists are fresh arrays, never interned), gated behind a
flag the loaders enable only when JOLT_TYPE_CHECK is on — zero cost off.
Keying by identity makes positions survive macroexpansion exactly when the
user's own sub-form is spliced through, and absent for macro-synthesized
structure: a `(inc :k)` written inside `(when c ...)` reports at its own
line, never at the expansion's generated if/do.
The analyzer stamps the offset onto :invoke nodes (form-position host
contract fn); the checker carries it into each diagnostic as :pos; the
loaders stash the file's source + path on the env (save/restored across
nested requires); backend/type-check! converts offset -> line:col via the
reader's line-col and renders the RFC format. Falls back to the ns when no
position is available (synthetic forms), so it is never worse than before.
Gate green, conformance 335/335, suite 4718, runtime bench even (positions
are compile-time only; off by default).
This commit is contained in:
parent
824b30defd
commit
f2d65addc8
9 changed files with 106 additions and 17 deletions
|
|
@ -23,7 +23,7 @@
|
||||||
form-map-pairs form-set-items form-special? compile-ns
|
form-map-pairs form-set-items form-special? compile-ns
|
||||||
form-macro? form-expand-1 resolve-global
|
form-macro? form-expand-1 resolve-global
|
||||||
form-sym-meta host-intern! form-syntax-quote-lower
|
form-sym-meta host-intern! form-syntax-quote-lower
|
||||||
record-type?]]))
|
record-type? form-position]]))
|
||||||
|
|
||||||
(declare analyze)
|
(declare analyze)
|
||||||
|
|
||||||
|
|
@ -250,8 +250,13 @@
|
||||||
(and (form-sym? head) (not shadowed) (form-macro? ctx head))
|
(and (form-sym? head) (not shadowed) (form-macro? ctx head))
|
||||||
(analyze ctx (form-expand-1 ctx form) env)
|
(analyze ctx (form-expand-1 ctx form) env)
|
||||||
:else
|
:else
|
||||||
(invoke (analyze ctx head env)
|
;; stamp the list form's source offset onto the :invoke (jolt-fqy)
|
||||||
(mapv #(analyze ctx % env) (rest items))))))))
|
;; so the success checker can report file:line:col. nil when the
|
||||||
|
;; reader did not record it (synthetic/macro-built forms).
|
||||||
|
(let [n (invoke (analyze ctx head env)
|
||||||
|
(mapv #(analyze ctx % env) (rest items)))
|
||||||
|
p (form-position form)]
|
||||||
|
(if p (assoc n :pos p) n)))))))
|
||||||
|
|
||||||
(defn analyze
|
(defn analyze
|
||||||
([ctx form] (analyze ctx form (empty-env)))
|
([ctx form] (analyze ctx form (empty-env)))
|
||||||
|
|
|
||||||
|
|
@ -1133,15 +1133,16 @@
|
||||||
|
|
||||||
(defn- check-invoke
|
(defn- check-invoke
|
||||||
"If node is a core-op call whose argument type is provably in the error domain,
|
"If node is a core-op call whose argument type is provably in the error domain,
|
||||||
conj a diagnostic. arg-types is the vector of inferred argument types."
|
conj a diagnostic. arg-types is the vector of inferred argument types; pos is
|
||||||
[cn args arg-types]
|
the call form's source offset (jolt-fqy), carried into each diagnostic."
|
||||||
|
[cn args arg-types pos]
|
||||||
(cond
|
(cond
|
||||||
(contains? num-ops cn)
|
(contains? num-ops cn)
|
||||||
(reduce (fn [_ i]
|
(reduce (fn [_ i]
|
||||||
(let [t (nth arg-types i)]
|
(let [t (nth arg-types i)]
|
||||||
(when (not-number? t)
|
(when (not-number? t)
|
||||||
(swap! diag-box conj
|
(swap! diag-box conj
|
||||||
{:op cn :argpos i :type (type-name t)
|
{:op cn :argpos i :type (type-name t) :pos pos
|
||||||
:msg (str "`" cn "` requires a number, but argument "
|
:msg (str "`" cn "` requires a number, but argument "
|
||||||
(inc i) " is " (type-name t))})))
|
(inc i) " is " (type-name t))})))
|
||||||
nil)
|
nil)
|
||||||
|
|
@ -1150,7 +1151,7 @@
|
||||||
(let [t (nth arg-types 0)]
|
(let [t (nth arg-types 0)]
|
||||||
(when (not-seqable? t)
|
(when (not-seqable? t)
|
||||||
(swap! diag-box conj
|
(swap! diag-box conj
|
||||||
{:op cn :argpos 0 :type (type-name t)
|
{:op cn :argpos 0 :type (type-name t) :pos pos
|
||||||
:msg (str "`" cn "` requires "
|
:msg (str "`" cn "` requires "
|
||||||
(if (= cn "count") "a countable collection" "a seqable")
|
(if (= cn "count") "a countable collection" "a seqable")
|
||||||
", but argument 1 is " (type-name t))})))
|
", but argument 1 is " (type-name t))})))
|
||||||
|
|
@ -1200,7 +1201,7 @@
|
||||||
did not already have means the argument alone is provably wrong. Monotonic —
|
did not already have means the argument alone is provably wrong. Monotonic —
|
||||||
binding a concrete type can only ADD error-domain hits — so no false positive.
|
binding a concrete type can only ADD error-domain hits — so no false positive.
|
||||||
Cycle-guarded so mutually recursive fns terminate."
|
Cycle-guarded so mutually recursive fns terminate."
|
||||||
[key sig arg-types]
|
[key sig arg-types pos]
|
||||||
(when (not (contains? @checking-box key))
|
(when (not (contains? @checking-box key))
|
||||||
(let [prev @checking-box]
|
(let [prev @checking-box]
|
||||||
(reset! checking-box (conj prev key))
|
(reset! checking-box (conj prev key))
|
||||||
|
|
@ -1217,7 +1218,7 @@
|
||||||
(let [pe (assoc (all-any-env params) (nth params i) at)]
|
(let [pe (assoc (all-any-env params) (nth params i) at)]
|
||||||
(when (> (isolated-diag-count body pe) base)
|
(when (> (isolated-diag-count body pe) base)
|
||||||
(swap! diag-box conj
|
(swap! diag-box conj
|
||||||
{:op :user-call :argpos i :type (type-name at)
|
{:op :user-call :argpos i :type (type-name at) :pos pos
|
||||||
:msg (str "argument " (inc i) " to `" (:name sig)
|
:msg (str "argument " (inc i) " to `" (:name sig)
|
||||||
"` is " (type-name at)
|
"` is " (type-name at)
|
||||||
", which its body provably rejects")})))))
|
", which its body provably rejects")})))))
|
||||||
|
|
@ -1240,9 +1241,10 @@
|
||||||
(str (get fnode :ns) "/" (get fnode :name)))
|
(str (get fnode :ns) "/" (get fnode :name)))
|
||||||
usig (when (and @strict-box ukey) (get @user-sig-box ukey))]
|
usig (when (and @strict-box ukey) (get @user-sig-box ukey))]
|
||||||
(when (or cn usig)
|
(when (or cn usig)
|
||||||
(let [ats (mapv (fn [a] (nth (infer a tenv) 0)) args)]
|
(let [ats (mapv (fn [a] (nth (infer a tenv) 0)) args)
|
||||||
(when cn (check-invoke cn args ats))
|
pos (get node :pos)]
|
||||||
(when usig (check-user-call ukey usig ats))))
|
(when cn (check-invoke cn args ats pos))
|
||||||
|
(when usig (check-user-call ukey usig ats pos))))
|
||||||
(check-walk fnode tenv)
|
(check-walk fnode tenv)
|
||||||
(reduce (fn [_ a] (check-walk a tenv) nil) nil args))
|
(reduce (fn [_ a] (check-walk a tenv) nil) nil args))
|
||||||
(= op :let)
|
(= op :let)
|
||||||
|
|
|
||||||
|
|
@ -369,5 +369,10 @@
|
||||||
Returns the result of the last form evaluated."
|
Returns the result of the last form evaluated."
|
||||||
[ctx s &opt file]
|
[ctx s &opt file]
|
||||||
(default file "<eval>")
|
(default file "<eval>")
|
||||||
|
# record form positions so the checker can report file:line:col (jolt-fqy)
|
||||||
|
(when (checker-enabled?)
|
||||||
|
(track-positions! true)
|
||||||
|
(put (ctx :env) :tc-source s)
|
||||||
|
(put (ctx :env) :tc-file file))
|
||||||
(eval-forms-positioned ctx (parse-all-positioned s file) file))
|
(eval-forms-positioned ctx (parse-all-positioned s file) file))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -633,9 +633,20 @@
|
||||||
(when (r 0)
|
(when (r 0)
|
||||||
(def diags (if (pv/pvec? (r 1)) (pv/pv->array (r 1)) (r 1)))
|
(def diags (if (pv/pvec? (r 1)) (pv/pv->array (r 1)) (r 1)))
|
||||||
(when (and diags (> (length diags) 0))
|
(when (and diags (> (length diags) 0))
|
||||||
(def loc (if ns (string ns) "?"))
|
# source + file for offset -> line:col (jolt-fqy). The loader stashes the
|
||||||
|
# current file's source + path on the env when checking is on.
|
||||||
|
(def src (get (ctx :env) :tc-source))
|
||||||
|
(def file (or (get (ctx :env) :tc-file) (and ns (string ns))))
|
||||||
(each d diags
|
(each d diags
|
||||||
(def msg (string "type error in " loc ": " (get d :msg)))
|
(def off (get d :pos))
|
||||||
|
# precise: file:line:col of the offending form when its offset and the
|
||||||
|
# source are both available; else the ns (no worse than before)
|
||||||
|
(def loc
|
||||||
|
(if (and off src)
|
||||||
|
(let [lc (r/line-col src off)]
|
||||||
|
(string (or file "?") ":" (in lc 0) ":" (in lc 1)))
|
||||||
|
(string "in " (if ns (string ns) "?"))))
|
||||||
|
(def msg (string "type error " loc ": " (get d :msg)))
|
||||||
(if (= strictness "error")
|
(if (= strictness "error")
|
||||||
(error msg)
|
(error msg)
|
||||||
(eprint " " msg)))))))
|
(eprint " " msg)))))))
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,18 @@
|
||||||
[ctx src &opt file]
|
[ctx src &opt file]
|
||||||
(default file "<source>")
|
(default file "<source>")
|
||||||
(def toplevel (get (ctx :env) :toplevel-eval))
|
(def toplevel (get (ctx :env) :toplevel-eval))
|
||||||
|
# a require runs nested inside an outer file's eval; save/restore the outer
|
||||||
|
# checker source so its later forms still convert offsets correctly (jolt-fqy)
|
||||||
|
(def checking (checker-enabled?))
|
||||||
|
(def saved-src (and checking (get (ctx :env) :tc-source)))
|
||||||
|
(def saved-file (and checking (get (ctx :env) :tc-file)))
|
||||||
|
(when checking
|
||||||
|
(track-positions! true)
|
||||||
|
(put (ctx :env) :tc-source src)
|
||||||
|
(put (ctx :env) :tc-file file))
|
||||||
|
(defer (when checking
|
||||||
|
(put (ctx :env) :tc-source saved-src)
|
||||||
|
(put (ctx :env) :tc-file saved-file))
|
||||||
(each [f line] (parse-all-positioned src file)
|
(each [f line] (parse-all-positioned src file)
|
||||||
(try
|
(try
|
||||||
(if toplevel (toplevel ctx f) (eval-form ctx @{} f))
|
(if toplevel (toplevel ctx f) (eval-form ctx @{} f))
|
||||||
|
|
@ -388,7 +400,7 @@
|
||||||
(when (nil? (get env :error-loading)) (put env :error-loading @[]))
|
(when (nil? (get env :error-loading)) (put env :error-loading @[]))
|
||||||
(def chain (get env :error-loading))
|
(def chain (get env :error-loading))
|
||||||
(when (not= (last chain) file) (array/push chain file))
|
(when (not= (last chain) file) (array/push chain file))
|
||||||
(propagate err fib)))))
|
(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
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,10 @@
|
||||||
# with it would recurse forever.
|
# with it would recurse forever.
|
||||||
(defn h-ref-get [tab key] (get tab key))
|
(defn h-ref-get [tab key] (get tab key))
|
||||||
|
|
||||||
|
# Absolute source offset of a list FORM (jolt-fqy), or nil. The analyzer stamps
|
||||||
|
# it onto :invoke nodes so the success checker can report file:line:col.
|
||||||
|
(defn h-form-position [form] (rdr/form-pos form))
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Inline registry (jolt-87f, Route 1 AOT escape analysis). The inline pass
|
# Inline registry (jolt-87f, Route 1 AOT escape analysis). The inline pass
|
||||||
# (jolt.passes) is portable Clojure and can't read Janet var cells, so it asks
|
# (jolt.passes) is portable Clojure and can't read Janet var cells, so it asks
|
||||||
|
|
@ -246,7 +250,7 @@
|
||||||
"form-syntax-quote-lower" h-syntax-quote-lower
|
"form-syntax-quote-lower" h-syntax-quote-lower
|
||||||
"host-intern!" h-intern!
|
"host-intern!" h-intern!
|
||||||
"inline-enabled?" h-inline-enabled? "inline-ir" h-inline-ir
|
"inline-enabled?" h-inline-enabled? "inline-ir" h-inline-ir
|
||||||
"record-type?" h-record-type?})
|
"record-type?" h-record-type? "form-position" h-form-position})
|
||||||
|
|
||||||
(defn install! [ctx]
|
(defn install! [ctx]
|
||||||
(def ns (ctx-find-ns ctx "jolt.host"))
|
(def ns (ctx-find-ns ctx "jolt.host"))
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,10 @@
|
||||||
(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))
|
||||||
|
(when (checker-enabled?)
|
||||||
|
(track-positions! true)
|
||||||
|
(put (ctx :env) :tc-source source)
|
||||||
|
(put (ctx :env) :tc-file filepath))
|
||||||
(def pairs (parse-all-positioned source filepath))
|
(def pairs (parse-all-positioned source filepath))
|
||||||
(var ns-name nil)
|
(var ns-name nil)
|
||||||
(each [form _] pairs
|
(each [form _] pairs
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,41 @@
|
||||||
# Forward declaration for mutual recursion
|
# Forward declaration for mutual recursion
|
||||||
(var read-form nil)
|
(var read-form nil)
|
||||||
|
|
||||||
|
# Source-position tracking for the success checker (jolt-fqy). When enabled, the
|
||||||
|
# reader records each LIST form's absolute start offset (lists are the forms that
|
||||||
|
# become :invoke nodes — what the checker reports on). Off by default: a flag
|
||||||
|
# check per list is the only cost when the checker isn't running. Keyed by form
|
||||||
|
# IDENTITY (lists are fresh arrays, never interned), so a position survives
|
||||||
|
# macroexpansion exactly when the user's own sub-form is spliced through, and is
|
||||||
|
# absent for macro-synthesized structure — which is what we want (fall back to
|
||||||
|
# the call site). Not cleared between parses: nested parses (a require mid-load)
|
||||||
|
# would otherwise drop an outer file's positions; the table is bounded by forms
|
||||||
|
# compiled this process and only populated when the checker is on.
|
||||||
|
(def form-pos-table @{})
|
||||||
|
(var track-positions false)
|
||||||
|
(var pos-base 0) # absolute offset of the slice read-form currently sees
|
||||||
|
|
||||||
|
(defn track-positions!
|
||||||
|
"Enable/disable list-form position recording (jolt-fqy)."
|
||||||
|
[on] (set track-positions on))
|
||||||
|
|
||||||
|
(defn set-pos-base!
|
||||||
|
"Tell the reader the absolute offset of the slice it is about to read, so
|
||||||
|
recorded list positions are absolute (parse-all-positioned reads a shrinking
|
||||||
|
remainder)."
|
||||||
|
[b] (set pos-base b))
|
||||||
|
|
||||||
|
(defn form-pos
|
||||||
|
"Absolute start offset recorded for a list form, or nil."
|
||||||
|
[form] (get form-pos-table form))
|
||||||
|
|
||||||
|
(defn checker-enabled?
|
||||||
|
"True when JOLT_TYPE_CHECK selects a non-off level — the loaders use this to
|
||||||
|
decide whether to record form positions for the checker (jolt-fqy)."
|
||||||
|
[]
|
||||||
|
(def tc (os/getenv "JOLT_TYPE_CHECK"))
|
||||||
|
(if (and tc (not= tc "off") (not= tc "0")) true false))
|
||||||
|
|
||||||
(def whitespace-chars " \t\n\r,")
|
(def whitespace-chars " \t\n\r,")
|
||||||
|
|
||||||
(defn whitespace? [c]
|
(defn whitespace? [c]
|
||||||
|
|
@ -624,7 +659,9 @@
|
||||||
|
|
||||||
# list
|
# list
|
||||||
(= c 40)
|
(= c 40)
|
||||||
(read-list s pos)
|
(let [r (read-list s pos)]
|
||||||
|
(when track-positions (put form-pos-table (in r 0) (+ pos-base pos)))
|
||||||
|
r)
|
||||||
|
|
||||||
# unmatched closing delimiters
|
# unmatched closing delimiters
|
||||||
(= c 41)
|
(= c 41)
|
||||||
|
|
@ -726,6 +763,9 @@
|
||||||
(or (= c (chr " ")) (= c (chr "\t")) (= c (chr "\r")) (= c (chr ","))) (++ 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))
|
(= c (chr ";")) (while (and (< i n) (not= (in s i) (chr "\n"))) (++ i))
|
||||||
(set scanning false)))
|
(set scanning false)))
|
||||||
|
# list-form positions recorded during this parse-next are relative to s;
|
||||||
|
# tell the reader the slice base so they land absolute (jolt-fqy)
|
||||||
|
(when track-positions (set-pos-base! (- (length source) (length s))))
|
||||||
(def [form rest*]
|
(def [form rest*]
|
||||||
(try (parse-next s)
|
(try (parse-next s)
|
||||||
([err fib]
|
([err fib]
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
(print "Success-type checking (jolt-y3b)...")
|
(print "Success-type checking (jolt-y3b)...")
|
||||||
|
|
||||||
(os/setenv "JOLT_DIRECT_LINK" "1")
|
(os/setenv "JOLT_DIRECT_LINK" "1")
|
||||||
|
(reader/track-positions! true) # record form positions (jolt-fqy)
|
||||||
(def ctx (api/init {:compile? true}))
|
(def ctx (api/init {:compile? true}))
|
||||||
(def pns (types/ctx-find-ns ctx "jolt.passes"))
|
(def pns (types/ctx-find-ns ctx "jolt.passes"))
|
||||||
(def check (types/var-get (types/ns-find pns "check-form")))
|
(def check (types/var-get (types/ns-find pns "check-form")))
|
||||||
|
|
@ -89,6 +90,11 @@
|
||||||
(def one (in (diags "(inc \"x\")") 0))
|
(def one (in (diags "(inc \"x\")") 0))
|
||||||
(assert (= "inc" (get one :op)) "diagnostic names the op")
|
(assert (= "inc" (get one :op)) "diagnostic names the op")
|
||||||
(assert (string/find "number" (get one :msg)) "message says a number is required")
|
(assert (string/find "number" (get one :msg)) "message says a number is required")
|
||||||
|
# --- the diagnostic carries the offending form's source offset (jolt-fqy) -----
|
||||||
|
(assert (= 0 (get one :pos)) "diagnostic carries :pos (offset 0 for a single form)")
|
||||||
|
(def nested (in (diags "(do 1 2 (inc :k))") 0))
|
||||||
|
(assert (= 8 (get nested :pos))
|
||||||
|
"the inner (inc :k) form is positioned at its own offset, not the do's")
|
||||||
|
|
||||||
# --- end-to-end: strictness drives compilation (decoupled from :inline?) -----
|
# --- end-to-end: strictness drives compilation (decoupled from :inline?) -----
|
||||||
# error mode aborts a provably-wrong form's compilation; a correct form compiles.
|
# error mode aborts a provably-wrong form's compilation; a correct form compiles.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue