From 130a6c5c4d8674c8622f73cf05e77259d5bc3161 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Mon, 15 Jun 2026 05:09:02 -0400 Subject: [PATCH] Refactor phase 3b: keep :try IR nodes structs, not phms (jolt-26dm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit analyze-try assoc'd :catch-sym/:catch-body/:finally nil-when-absent, so a try with no catch (or no finally) carried a nil-valued key — which makes the node a phm in jolt's map representation and forces the back end to densify it (norm-node) before reading :op. That's the map-nil-representation trap Phase 2 already cleaned up for def/fn/arity nodes. Add those keys only when the clause is present, matching the arity :rest discipline; a try node stays a fast struct. Behavior-invisible: emit-try reads each key with a nil-safe (node :k) and gates on it, so an absent key and a present-nil key are indistinguishable to every consumer. Adds ir-try-shape-test asserting the node shape across all four try/catch/finally combinations plus end-to-end eval. Note on scope: the plan's "delete the defensive norm-node calls" is NOT done — it can't be. {:op :const :val nil} (e.g. (def x nil)) and nil map keys are inherently phm, so the emit-dispatch norm-node guards a real case, not a present-or-absent artifact. This PR removes a source of gratuitous phm nodes rather than the densification itself. Full gate green. --- jolt-core/jolt/analyzer.clj | 22 ++++++++---- test/integration/ir-try-shape-test.janet | 43 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 test/integration/ir-try-shape-test.janet diff --git a/jolt-core/jolt/analyzer.clj b/jolt-core/jolt/analyzer.clj index bbd00ec..81bf2b5 100644 --- a/jolt-core/jolt/analyzer.clj +++ b/jolt-core/jolt/analyzer.clj @@ -167,12 +167,22 @@ (= hname "finally") (reset! finally-body (rest (vec (form-elements c)))) :else (swap! body conj c)))) - {:op :try - :body (analyze-seq ctx @body env) - :catch-sym @catch-sym - :catch-body (when @catch-body - (analyze-seq ctx @catch-body (add-locals env [@catch-sym]))) - :finally (when @finally-body (analyze-seq ctx @finally-body env))})) + ;; Add :catch-sym/:catch-body/:finally ONLY when present (same discipline as + ;; the arity :rest key above). Assoc'ing them nil-when-absent would give the + ;; node a nil-valued key, which makes it a phm in jolt's map representation + ;; and forces the back end to densify it (norm-node) before reading :op — the + ;; map-nil-representation trap Phase 2 cleaned up for def/fn/arity nodes. The + ;; back end reads each key with a nil-safe (node :k) and gates on it, so an + ;; absent key is indistinguishable from a present-nil one. + (let [n {:op :try :body (analyze-seq ctx @body env)} + n (if @catch-body + (assoc n :catch-sym @catch-sym + :catch-body (analyze-seq ctx @catch-body (add-locals env [@catch-sym]))) + n) + n (if @finally-body + (assoc n :finally (analyze-seq ctx @finally-body env)) + n)] + n))) (defn- analyze-special [ctx op items env] (case op diff --git a/test/integration/ir-try-shape-test.janet b/test/integration/ir-try-shape-test.janet new file mode 100644 index 0000000..54c3c50 --- /dev/null +++ b/test/integration/ir-try-shape-test.janet @@ -0,0 +1,43 @@ +# IR shape hygiene for :try nodes (phase 3b, jolt-26dm). analyze-try used to +# assoc :catch-sym/:catch-body/:finally nil-when-absent, which made the node a +# phm (jolt's nil-valued-key map representation) and forced backend densification +# before every :op read. It now adds those keys only when the clause is present +# — same discipline as the arity :rest key — so a try node stays a fast struct. +# The change is behavior-invisible (the back end reads each key nil-safely and +# gates on it), so we also pin that tries still evaluate correctly. +(import ../../src/jolt/api :as api) +(import ../../src/jolt/backend :as backend) +(import ../../src/jolt/reader :as reader) + +(print "IR :try shape...") +(def ctx (api/init-cached {:compile? true})) +(defn try-node [src] (backend/analyze-form ctx (reader/parse-string src))) + +(defn check-struct [src] + (def n (try-node src)) + (assert (= :try (n :op)) (string src " is a :try node")) + # a struct (nil-free) — NOT a phm; phm nodes carry a :jolt/deftype tag + (assert (struct? n) (string src " analyzes to a struct, not a phm")) + (assert (nil? (get n :jolt/deftype)) (string src " is not a phm"))) + +# no catch, no finally — neither optional key should appear +(check-struct "(try 1)") +# finally only — :catch-sym/:catch-body must be ABSENT, not nil +(def fin (try-node "(try 1 (finally 2))")) +(assert (struct? fin) "(try .. finally) is a struct") +(assert (nil? (get fin :catch-body)) "no catch-body when there is no catch") +(assert (get fin :finally) "finally present") +# catch only +(def cat (try-node "(try 1 (catch Throwable e 2))")) +(assert (struct? cat) "(try .. catch) is a struct") +(assert (get cat :catch-sym) "catch-sym present") +(assert (nil? (get cat :finally)) "no finally when there is none") +# catch + finally +(check-struct "(try 1 (catch Throwable e 2) (finally 3))") + +# behavior unchanged across all shapes +(assert (= 1 (api/eval-string ctx "(try 1)")) "try value") +(assert (= 2 (api/eval-string ctx "(try (throw (ex-info \"x\" {})) (catch Throwable e 2))")) "catch value") +(assert (= 7 (api/eval-string ctx "(let [a (atom 0)] (try (reset! a 7) (finally nil)) @a)")) "finally runs") +(assert (= 2 (api/eval-string ctx "(try (throw (ex-info \"x\" {})) (catch Throwable e 2) (finally 9))")) "catch+finally") +(print "IR :try shape passed!")