From 145035d42453d35c9bbd49b602b479faa067e2e6 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Sat, 6 Jun 2026 01:43:40 -0400 Subject: [PATCH] compiler phase 2: hybrid interpret/compile fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eval-one now tries to compile each form and falls back to the interpreter for anything the compiler can't handle, instead of erroring or silently miscompiling. Only the compile step is guarded, so runtime errors in compiled code still propagate (no double-eval of side effects, no hidden errors). The compiler now throws jolt/uncompilable on the forms it would otherwise miscompile — destructuring let/loop bindings and fn params, multi-arity fns, and named fns — so they route to the interpreter and produce correct results. These become compile targets in a later optimization pass. Adds eval-compiled to split compile from eval; compile-mode tests cover destructuring, multi-arity, named-fn recursion, and error propagation. --- src/jolt/api.janet | 18 ++++++++++-- src/jolt/compiler.janet | 37 ++++++++++++++++++++++-- test/integration/compile-mode-test.janet | 20 ++++++++++++- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/jolt/api.janet b/src/jolt/api.janet index 8f568ae..98713e8 100644 --- a/src/jolt/api.janet +++ b/src/jolt/api.janet @@ -65,9 +65,21 @@ (= head-name "var") (= head-name ".") (= head-name "new") (= head-name "eval"))) +# Hybrid compile path: try to compile the form, fall back to the interpreter for +# anything the compiler can't (yet) handle. Only the compile step is guarded — +# runtime errors in compiled code propagate, so we never double-eval side effects +# or hide real errors behind a fallback. The interpreter handles every form, so +# the result is always correct; compilation is a transparent speedup. +(defn- try-compile-eval [ctx form] + (let [compiled (protect (compile-ast form ctx))] + (if (compiled 0) + (eval-compiled (compiled 1) ctx) + (eval-form ctx @{} form)))) + (defn eval-one "Evaluate a single already-parsed form, routing to the compiler when the - context has :compile? enabled (stateful forms always interpret)." + context has :compile? enabled (stateful forms always interpret; forms the + compiler can't handle fall back to the interpreter)." [ctx form] (if (get (ctx :env) :compile?) (if (array? form) @@ -76,9 +88,9 @@ (first-form :name) nil)] (if (stateful-head? head-name) (eval-form ctx @{} form) - (compile-and-eval form ctx))) + (try-compile-eval ctx form))) (if (or (and (struct? form) (= :symbol (form :jolt/type))) (tuple? form)) - (compile-and-eval form ctx) + (try-compile-eval ctx form) (eval-form ctx @{} form))) (eval-form ctx @{} form))) diff --git a/src/jolt/compiler.janet b/src/jolt/compiler.janet index ddc8685..6cc234e 100644 --- a/src/jolt/compiler.janet +++ b/src/jolt/compiler.janet @@ -352,6 +352,19 @@ # Analyzer # ============================================================ +(defn- plain-symbol? + "A bare Clojure symbol (not a destructuring pattern). `&` counts — it's the + varargs marker, which the emitter passes straight through to Janet." + [x] + (and (struct? x) (= :symbol (x :jolt/type)))) + +(defn- uncompilable + "Signal that the compiler can't (yet) handle this form. eval-one catches this + and falls back to the interpreter, which handles every form correctly. Throwing + here — rather than miscompiling — is what makes the hybrid path sound." + [reason] + (error (string "jolt/uncompilable: " reason))) + (defn analyze-form "Analyze a Clojure form and return an AST node with :op key. Takes bindings (table) and optional ctx (for macro expansion)." @@ -449,6 +462,15 @@ {:op :def :name name-sym :var cell :init (analyze-form (in form 2) bindings ctx)}) "fn*" (let [params (in form 1) + _ (do + # Named fns put a symbol at position 1; multi-arity + # puts a list of clauses. Both, plus destructuring + # params, fall back to the interpreter. + (when (plain-symbol? params) (uncompilable "named fn")) + (unless (tuple? params) (uncompilable "multi-arity fn")) + (each p params + (unless (plain-symbol? p) + (uncompilable "destructuring fn params")))) body-bindings (do (var bb @{}) (loop [[k v] :pairs bindings] (put bb k v)) @@ -472,7 +494,9 @@ (let [n (length bind-vec)] (while (< i n) (let [sym-s (in bind-vec i) - name (if (struct? sym-s) (sym-s :name) sym-s) + _ (unless (plain-symbol? sym-s) + (uncompilable "destructuring let binding")) + name (sym-s :name) val-form (if (< (+ i 1) n) (in bind-vec (+ i 1)) nil) val-ast (if val-form (analyze-form val-form bindings ctx) {:op :const :val nil})] (array/push pairs {:name name :init val-ast}) @@ -501,7 +525,9 @@ (let [n (length bind-vec)] (while (< i n) (let [sym-s (in bind-vec i) - name (if (struct? sym-s) (sym-s :name) sym-s) + _ (unless (plain-symbol? sym-s) + (uncompilable "destructuring loop binding")) + name (sym-s :name) val-form (if (< (+ i 1) n) (in bind-vec (+ i 1)) nil) val-ast (if val-form (analyze-form val-form bindings ctx) {:op :const :val nil})] (array/push pairs {:name name :init val-ast}) @@ -926,3 +952,10 @@ named-fn rewrite needed." [form ctx] (eval (compile-ast form ctx) (ctx-janet-env ctx))) + +(defn eval-compiled + "Evaluate an already-compiled Janet form (the result of compile-ast) in the + context's compiled env. Split out from compile-and-eval so callers can guard + the compile step alone — see eval-one's hybrid fallback." + [compiled ctx] + (eval compiled (ctx-janet-env ctx))) diff --git a/test/integration/compile-mode-test.janet b/test/integration/compile-mode-test.janet index df9ae1f..2e68d93 100644 --- a/test/integration/compile-mode-test.janet +++ b/test/integration/compile-mode-test.janet @@ -98,7 +98,25 @@ (assert (= 1 (ct-eval ctx "(:a {:a 1})")) "keyword as fn") (assert (= 1 (ct-eval ctx "({:a 1} :a)")) "map as fn") (assert (= 2 (ct-eval ctx "(#{1 2 3} 2)")) "set as fn") - (assert (= true (ct-eval ctx "(= [1 2] [1 2])")) "= is value equality, not core-= bypass")) + (assert (= true (ct-eval ctx "(= [1 2] [1 2])")) "= is value equality, not core-= bypass") + + # Phase 2: hybrid fallback. Forms the compiler can't compile (destructuring, + # multi-arity, named fns) interpret instead of erroring or miscompiling. The + # result is the same — compilation is a transparent speedup. + (print " hybrid fallback (destructuring / multi-arity)...") + (assert (= 3 (ct-eval ctx "(let [[a b] [1 2]] (+ a b))")) "vector destructuring let") + (assert (= 6 (ct-eval ctx "(let [{:keys [x y z]} {:x 1 :y 2 :z 3}] (+ x y z))")) "map destructuring let") + (assert (= 3 (ct-eval ctx "((fn [[a b]] (+ a b)) [1 2])")) "destructuring fn param") + (assert (= 5 (ct-eval ctx "(let [[a & more] [1 2 3 4 5]] (+ a (count more)))")) "rest destructuring") + (ct-eval ctx "(defn arity ([a] a) ([a b] (+ a b)))") + (assert (= 5 (ct-eval ctx "(arity 5)")) "multi-arity 1") + (assert (= 7 (ct-eval ctx "(arity 3 4)")) "multi-arity 2") + (assert (= 10 (ct-eval ctx "((fn self [n] (if (zero? n) 0 (+ n (self (dec n))))) 4)")) "named fn recursion") + (assert (= 6 (ct-eval ctx "(loop [[x & xs] [1 2 3] acc 0] (if x (recur xs (+ acc x)) acc))")) "destructuring loop binding") + # A runtime error in compiled code must propagate, not silently fall back to a + # second (interpreted) evaluation. + (assert (= :threw (try (do (ct-eval ctx "(inc nil)") :no-throw) ([_] :threw))) + "runtime error in compiled code propagates")) # Context isolation: a def in one compiled context is invisible in another. With # var-indirection each context has its own var cells, so b's `secret` is a