diff --git a/jolt-core/jolt/analyzer.clj b/jolt-core/jolt/analyzer.clj index 2ce9f44..f56ee38 100644 --- a/jolt-core/jolt/analyzer.clj +++ b/jolt-core/jolt/analyzer.clj @@ -23,7 +23,7 @@ form-map-pairs form-set-items form-special? compile-ns form-macro? form-expand-1 resolve-global form-sym-meta host-intern! form-syntax-quote-lower - record-type?]])) + record-type? form-position]])) (declare analyze) @@ -250,8 +250,13 @@ (and (form-sym? head) (not shadowed) (form-macro? ctx head)) (analyze ctx (form-expand-1 ctx form) env) :else - (invoke (analyze ctx head env) - (mapv #(analyze ctx % env) (rest items)))))))) + ;; stamp the list form's source offset onto the :invoke (jolt-fqy) + ;; 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 ([ctx form] (analyze ctx form (empty-env))) diff --git a/jolt-core/jolt/passes.clj b/jolt-core/jolt/passes.clj index 63e221b..4306599 100644 --- a/jolt-core/jolt/passes.clj +++ b/jolt-core/jolt/passes.clj @@ -1133,15 +1133,16 @@ (defn- check-invoke "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." - [cn args arg-types] + conj a diagnostic. arg-types is the vector of inferred argument types; pos is + the call form's source offset (jolt-fqy), carried into each diagnostic." + [cn args arg-types pos] (cond (contains? num-ops cn) (reduce (fn [_ i] (let [t (nth arg-types i)] (when (not-number? t) (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 " (inc i) " is " (type-name t))}))) nil) @@ -1150,7 +1151,7 @@ (let [t (nth arg-types 0)] (when (not-seqable? t) (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 " (if (= cn "count") "a countable collection" "a seqable") ", but argument 1 is " (type-name t))}))) @@ -1200,7 +1201,7 @@ 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. Cycle-guarded so mutually recursive fns terminate." - [key sig arg-types] + [key sig arg-types pos] (when (not (contains? @checking-box key)) (let [prev @checking-box] (reset! checking-box (conj prev key)) @@ -1217,7 +1218,7 @@ (let [pe (assoc (all-any-env params) (nth params i) at)] (when (> (isolated-diag-count body pe) base) (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) "` is " (type-name at) ", which its body provably rejects")}))))) @@ -1240,9 +1241,10 @@ (str (get fnode :ns) "/" (get fnode :name))) usig (when (and @strict-box ukey) (get @user-sig-box ukey))] (when (or cn usig) - (let [ats (mapv (fn [a] (nth (infer a tenv) 0)) args)] - (when cn (check-invoke cn args ats)) - (when usig (check-user-call ukey usig ats)))) + (let [ats (mapv (fn [a] (nth (infer a tenv) 0)) args) + pos (get node :pos)] + (when cn (check-invoke cn args ats pos)) + (when usig (check-user-call ukey usig ats pos)))) (check-walk fnode tenv) (reduce (fn [_ a] (check-walk a tenv) nil) nil args)) (= op :let) diff --git a/src/jolt/api.janet b/src/jolt/api.janet index ed1d6b8..c657829 100644 --- a/src/jolt/api.janet +++ b/src/jolt/api.janet @@ -369,5 +369,10 @@ Returns the result of the last form evaluated." [ctx s &opt file] (default file "") + # 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)) diff --git a/src/jolt/backend.janet b/src/jolt/backend.janet index 678fe05..5181eb5 100644 --- a/src/jolt/backend.janet +++ b/src/jolt/backend.janet @@ -633,9 +633,20 @@ (when (r 0) (def diags (if (pv/pvec? (r 1)) (pv/pv->array (r 1)) (r 1))) (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 - (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") (error msg) (eprint " " msg))))))) diff --git a/src/jolt/evaluator.janet b/src/jolt/evaluator.janet index 694b783..72cc465 100644 --- a/src/jolt/evaluator.janet +++ b/src/jolt/evaluator.janet @@ -375,6 +375,18 @@ [ctx src &opt file] (default file "") (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) (try (if toplevel (toplevel ctx f) (eval-form ctx @{} f)) @@ -388,7 +400,7 @@ (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))))) + (propagate err fib)))))) (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/host_iface.janet b/src/jolt/host_iface.janet index aed1a76..2bcf20e 100644 --- a/src/jolt/host_iface.janet +++ b/src/jolt/host_iface.janet @@ -194,6 +194,10 @@ # with it would recurse forever. (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 # (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 "host-intern!" h-intern! "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] (def ns (ctx-find-ns ctx "jolt.host")) diff --git a/src/jolt/loader.janet b/src/jolt/loader.janet index 70a76b6..d7e2028 100644 --- a/src/jolt/loader.janet +++ b/src/jolt/loader.janet @@ -112,6 +112,10 @@ (load-ns ctx filepath) → namespace symbol string" [ctx 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)) (var ns-name nil) (each [form _] pairs diff --git a/src/jolt/reader.janet b/src/jolt/reader.janet index 7b01ec9..a6220f7 100644 --- a/src/jolt/reader.janet +++ b/src/jolt/reader.janet @@ -14,6 +14,41 @@ # Forward declaration for mutual recursion (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,") (defn whitespace? [c] @@ -624,7 +659,9 @@ # list (= 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 (= c 41) @@ -726,6 +763,9 @@ (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))) + # 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*] (try (parse-next s) ([err fib] diff --git a/test/integration/type-check-test.janet b/test/integration/type-check-test.janet index 8c7e25f..28b5088 100644 --- a/test/integration/type-check-test.janet +++ b/test/integration/type-check-test.janet @@ -10,6 +10,7 @@ (print "Success-type checking (jolt-y3b)...") (os/setenv "JOLT_DIRECT_LINK" "1") +(reader/track-positions! true) # record form positions (jolt-fqy) (def ctx (api/init {:compile? true})) (def pns (types/ctx-find-ns ctx "jolt.passes")) (def check (types/var-get (types/ns-find pns "check-form"))) @@ -89,6 +90,11 @@ (def one (in (diags "(inc \"x\")") 0)) (assert (= "inc" (get one :op)) "diagnostic names the op") (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?) ----- # error mode aborts a provably-wrong form's compilation; a correct form compiles.