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