From d35783bf1baf81dbb3e9b7d4dbae39bb3b550f56 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Wed, 17 Jun 2026 17:25:14 -0400 Subject: [PATCH] Reject a malformed catch clause with a clean error in both modes jolt's catch is (catch class binding body*); the binding (3rd element) must be a symbol. Neither the analyzer nor the interpreter validated it, so a non-symbol binding crashed with an internal Janet error (expected integer key for array...) and, in the interpreter, a malformed clause whose body never threw was silently swallowed (returned the try value). Clojure rejects a non-class/non-symbol catch clause; match that with an up-front error in analyze-try and eval-try. Surfaced building the Chez try/throw emit. Regression rows in exceptions-spec (runs x3 modes) plus a unit test asserting the clean message in interpret and compile. jolt-kg6p. --- jolt-core/jolt/analyzer.clj | 6 +++ src/jolt/eval_special.janet | 7 ++++ test/spec/exceptions-spec.janet | 7 +++- test/unit/try-catch-validation-test.janet | 48 +++++++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 test/unit/try-catch-validation-test.janet diff --git a/jolt-core/jolt/analyzer.clj b/jolt-core/jolt/analyzer.clj index 81bf2b5..430bd39 100644 --- a/jolt-core/jolt/analyzer.clj +++ b/jolt-core/jolt/analyzer.clj @@ -162,6 +162,12 @@ (cond (= hname "catch") (let [cl (vec (form-elements c))] + ;; (catch class binding body*) — binding (3rd elem) MUST be a symbol. + ;; Validate eagerly (plain throw, NOT uncompilable, so it's a real + ;; error rather than a compile->interpret punt) instead of letting + ;; form-sym-name crash on a non-symbol. + (when (or (< (count cl) 3) (not (form-sym? (nth cl 2)))) + (throw "Unable to parse catch clause; expected (catch class binding body*)")) (reset! catch-sym (form-sym-name (nth cl 2))) (reset! catch-body (drop 3 cl))) (= hname "finally") diff --git a/src/jolt/eval_special.janet b/src/jolt/eval_special.janet index d748f1e..2ce291c 100644 --- a/src/jolt/eval_special.janet +++ b/src/jolt/eval_special.janet @@ -435,6 +435,13 @@ (when (and (struct? head) (= :symbol (head :jolt/type))) (match (head :name) "catch" (do + # (catch class binding body*) — binding (3rd elem) must + # be a symbol. Validate up front (even when the body + # never throws) so a malformed clause is a clean error, + # not a silent nil or an internal crash when caught. + (let [b (when (>= (length clause) 3) (in clause 2))] + (unless (and b (struct? b) (= :symbol (b :jolt/type))) + (error "Unable to parse catch clause; expected (catch class binding body*)"))) (set catch-sym (in clause 2)) (set catch-body (tuple/slice clause 3))) "finally" (set finally-body (tuple/slice clause 1))))))) diff --git a/test/spec/exceptions-spec.janet b/test/spec/exceptions-spec.janet index ce89a14..1249d4d 100644 --- a/test/spec/exceptions-spec.janet +++ b/test/spec/exceptions-spec.janet @@ -15,7 +15,12 @@ ["finally runs on throw" "9" "(let [a (atom 0)] (try (throw (ex-info \"x\" {})) (catch :default e nil) (finally (reset! a 9))) @a)"] ["catch value of body" "5" - "(try (+ 2 3) (catch :default e 0))"]) + "(try (+ 2 3) (catch :default e 0))"] + # a malformed catch clause (binding not a symbol, or clause too short) is a + # clean error, not a silent nil or an internal crash (jolt-kg6p). + ["malformed catch: non-symbol binding" :throws "(try 1 (catch e (* e 10)))"] + ["malformed catch: literal binding" :throws "(try 1 (catch e 5))"] + ["malformed catch: too short" :throws "(try 1 (catch Exception))"]) (defspec "exceptions / assert" ["assert true -> ok" ":ok" "(do (assert true) :ok)"] diff --git a/test/unit/try-catch-validation-test.janet b/test/unit/try-catch-validation-test.janet new file mode 100644 index 0000000..6ef0782 --- /dev/null +++ b/test/unit/try-catch-validation-test.janet @@ -0,0 +1,48 @@ +# A malformed catch clause must be rejected with a clean, Clojure-like error in +# BOTH the interpreter and the compiler — not an internal Janet crash ("expected +# integer key for array …") and not silently swallowed. jolt's catch is +# (catch Class binding body*); the binding (3rd element) must be a symbol. +# Regression for jolt-kg6p (surfaced building the Chez try/throw emit). +(import ../../src/jolt/api :as api) + +(var total 0) (var fails 0) +(defn ok [name pred &opt extra] + (++ total) + (if pred (printf "ok: %s" name) + (do (++ fails) (printf "FAIL: %s %s" name (or extra ""))))) + +(defn err-msg [ctx s] + (let [r (protect (api/eval-string ctx s))] + (if (r 0) :no-error (string (r 1))))) + +(defn val-of [ctx s] (api/eval-string ctx s)) + +# malformed: the binding position holds a non-symbol (a call form / a literal), +# or the clause is too short. Each must raise a clean error mentioning catch, +# and must NOT leak the internal Janet indexing crash. +(def malformed + ["(try 1 (catch e (* e 10)))" + "(try 1 (catch e 5))" + "(try 1 (catch Exception))"]) + +# well-formed catch still works (class is a symbol or :default; binding a symbol). +(def wellformed + [["(try (throw 7) (catch Exception e (* e 10)))" 70] + ["(try (throw 42) (catch :default e e))" 42] + ["(try (+ 2 3) (catch :default e 0))" 5]]) + +(each [mode opts] [["interpret" {}] ["compile" {:compile? true}]] + (def ctx (api/init opts)) + (each s malformed + (def m (err-msg ctx s)) + (ok (string mode " rejects: " s) + (and (not= m :no-error) + (string/find "catch" m) + (not (string/find "expected integer key" m))) + (string "msg=" m))) + (each [s want] wellformed + (ok (string mode " ok: " s) (= want (val-of ctx s)) + (string "got " (val-of ctx s))))) + +(printf "\ntry-catch-validation: %d/%d passed" (- total fails) total) +(when (> fails 0) (error (string fails " failing")))