Fix arg evaluation order + host interop gaps so reitit/selmer/honeysql run

Shaking the ring-app example's real library stack out against jolt surfaced a
batch of divergences from JVM Clojure, the biggest being evaluation order.

backend_scheme: call and recur arguments were emitted as bare Scheme operands,
so Chez's unspecified (right-to-left) order won out. Clojure evaluates left to
right, which selmer's reader loop relies on: (recur (add-node ... rdr) (read-char
rdr)) consumed a char early and dropped the first chars of every {{tag}}. Bind
operands to fresh temps in a let* (only when two or more can have side effects,
so hot calls over locals/consts stay un-wrapped). emit-ordered already did this
for collection literals; generalize it.

host-contract: syntax-quote now resolves the alias part of a qualified symbol
(impl/foo -> clojure.tools.logging.impl/foo) instead of leaving it bare, which
limped along via short-name matching until two loaded namespaces (reitit.impl,
clojure.tools.logging.impl) shared the short name and it broke.

collections: key-hash masks with bitwise-and, not fxand — jolt-hash is set!-
decorated per type (records return their own hash) and Chez's equal-hash can be a
bignum, so a key's hash isn't always a fixnum.

seq: even?/odd? handle bignums (JVM accepts any integer; the fxand crashed).

records: Keyword/Symbol .sym/.getName/.toString (honeysql's :clj branch reads
(.sym k)); Throwable .getMessage/.toString over a Chez condition.

host-static: __register-class-ctor!/__register-class-statics! so a host shim
(reitit.trie-jolt) can mirror a Java class.

natives-str: String.intern returns the string.

sqlite: jdbc.core fetch/fetch-one kebab-case column keys (the jolt-lang/db
convention; created_at -> :created-at).

io: a relative io/file path resolves against JOLT_PWD (the user's cwd), not the
repo root the launcher cd'd to — matches JVM cwd semantics, so config.edn loads.

cli: render an uncaught jolt throw (ex-info message + ex-data, or a condition)
instead of Chez's opaque "non-condition value" dump.
This commit is contained in:
Yogthos 2026-06-22 05:26:09 -04:00
parent 10e3a00777
commit 6ab65a30e3
12 changed files with 744 additions and 573 deletions

View file

@ -30,7 +30,32 @@
;; A project's resolved deps roots are prepended to these by jolt.main.
(set-source-roots! (list "jolt-core" "src/jolt"))
(cond
;; Render an uncaught jolt throw (any value, not just a Chez condition) to stderr
;; and exit non-zero, instead of Chez's opaque "non-condition value" dump. An
;; ex-info shows its message + ex-data; anything else is pr-str'd.
(define (jolt-report-uncaught v)
(let ((port (current-error-port)))
(if (and (jolt=2 (jolt-get v jolt-kw-ex-type jolt-nil) jolt-kw-ex-info))
(begin
(display "Unhandled exception: " port)
(display (jolt-str-render-one (jolt-get v jolt-kw-message jolt-nil)) port)
(newline port)
(let ((data (jolt-get v jolt-kw-data jolt-nil)))
(unless (jolt-nil? data)
(display " ex-data: " port) (display (jolt-pr-str data) port) (newline port)))
(let ((cause (jolt-get v jolt-kw-cause jolt-nil)))
(when (condition? cause)
(display " cause: " port)
(display (with-output-to-string (lambda () (display-condition cause))) port)
(newline port))))
(begin
(display "Unhandled exception: " port)
(display (if (condition? v) (with-output-to-string (lambda () (display-condition v))) (jolt-pr-str v)) port)
(newline port)))
(exit 1)))
(guard (v (#t (jolt-report-uncaught v)))
(cond
;; -e EXPR — evaluate one expression and print it (blank for nil). Wrapped in
;; (do …) so a multi-form string evaluates every form and returns the last.
((and (= (length cli-args) 2) (string=? (car cli-args) "-e"))
@ -42,4 +67,4 @@
(else
(load-namespace "jolt.main")
(let ((mainv (var-deref "jolt.main" "-main")))
(apply jolt-invoke mainv cli-args))))
(apply jolt-invoke mainv cli-args)))))

View file

@ -84,7 +84,11 @@
(define empty-hnode (make-hnode 0 (vector)))
(define hmask #x3FFFFFFFFFFFFFF) ; 58-bit non-negative hash window
(define max-shift 55)
(define (key-hash k) (fxand (jolt-hash k) hmask))
;; bitwise-and (not fxand): jolt-hash is set!-decorated per type (records/inst/
;; sorted return their own hash) and Chez's equal-hash can yield a BIGNUM, so a
;; key's hash isn't guaranteed to be a fixnum. Masking with the 58-bit window via
;; the generic bitwise-and always lands in fixnum range for the HAMT's fx slicing.
(define (key-hash k) (bitwise-and (jolt-hash k) hmask))
(define (chunk h shift) (fxand (fxsra h shift) 31))
(define (bitpos h shift) (fxsll 1 (chunk h shift)))
(define (popcount n) (let loop ((n n) (c 0)) (if (fx=? n 0) c (loop (fxand n (fx- n 1)) (fx+ c 1)))))

View file

@ -226,9 +226,14 @@
((hc-interop-head? nm) form) ; interop (.method / Class. / .-field): bare
((var-cell-lookup "clojure.core" nm) (jolt-symbol "clojure.core" nm))
(else (jolt-symbol (chez-actx-cns ctx) nm))) ; else: qualify to compile ns
;; qualified (a real ns or an alias): ns aliases aren't modeled on the Chez
;; data layer yet, so leave a qualified symbol as written (jolt-qjr0).
form)))
;; qualified: if the ns part is an :as alias in the compile ns, resolve it
;; to the target namespace — Clojure resolves the alias part of a qualified
;; symbol in syntax-quote, so a macro's `impl/foo` expands to its real
;; (clojure.tools.logging.impl/foo) name and stays unambiguous even when
;; another loaded ns shares the alias's short name (jolt-qjr0). Otherwise
;; leave it as written (a real ns or an interop class token).
(let ((target (chez-resolve-alias (chez-actx-cns ctx) sns)))
(if target (jolt-symbol target nm) form)))))
(define (hc-sq-lower ctx form gsmap)
(cond

View file

@ -609,3 +609,17 @@
(def-var! "clojure.core" "host-static-ref" host-static-ref)
(def-var! "clojure.core" "host-static-call" (lambda (c m . a) (apply host-static-call c m a)))
(def-var! "clojure.core" "host-new" (lambda (c . a) (apply host-new c a)))
;; Clojure-visible class-registration hooks. A host shim (e.g. reitit.trie-jolt,
;; which mirrors the reitit.Trie Java class) registers a constructor proc or a
;; map of static members against a class token so (Class. args) / (Class/member
;; args) resolve to it. The statics argument is a jolt map {member-name -> val}.
(define (jmap->static-alist m)
(let loop ((s (jolt-seq m)) (acc '()))
(if (jolt-nil? s) acc
(let ((e (jolt-first s)))
(loop (jolt-seq (jolt-rest s)) (cons (cons (jolt-nth e 0) (jolt-nth e 1)) acc))))))
(def-var! "clojure.core" "__register-class-ctor!"
(lambda (name proc) (register-class-ctor! name proc) jolt-nil))
(def-var! "clojure.core" "__register-class-statics!"
(lambda (name members) (register-class-statics! name (jmap->static-alist members)) jolt-nil))

View file

@ -20,9 +20,19 @@
;; path string of any value: a jfile -> its path, else its str rendering.
(define (file-path-of x) (if (jfile? x) (jfile-path x) (jolt-str-render-one x)))
;; A user-facing relative path resolves against JOLT_PWD — the user's cwd before
;; the launcher cd'd to the jolt repo root — matching the JVM, where io/file is
;; cwd-relative. (io/resource builds jfiles from the source roots directly, so it
;; isn't routed through here.)
(define (project-relative p)
(if (or (= (string-length p) 0) (char=? (string-ref p 0) #\/))
p
(let ((pwd (getenv "JOLT_PWD")))
(if (and pwd (> (string-length pwd) 0)) (string-append pwd "/" p) p))))
;; (io/file path) / (io/file parent child) — join children with "/".
(define (jolt-make-file path . rest)
(let loop ((p (file-path-of path)) (cs rest))
(let loop ((p (project-relative (file-path-of path))) (cs rest))
(if (null? cs) (make-jfile p)
(loop (string-append p "/" (file-path-of (car cs))) (cdr cs)))))

View file

@ -128,6 +128,9 @@
;; getMessage with itself; equals is value equality.
((or (string=? method "getMessage") (string=? method "getLocalizedMessage")) s)
((string=? method "equals") (and (string? (arg 0)) (string=? s (arg 0))))
;; String.intern: jolt strings aren't pooled, but value equality holds, so the
;; canonical representation is the string itself.
((string=? method "intern") s)
(else (error #f (string-append "No method " method " for value")))))
;; --- clojure.core str-* primitives (the substrate clojure.string.clj calls) ---

View file

@ -247,6 +247,15 @@
;; holding a mutable cursor over (seq coll); (.hasNext it)/(.next it) walk it.
;; hiccup/compiler's run! loop iterates collections this way.
(define-record-type jiterator (fields (mutable cur)) (nongenerative jolt-iterator-v1))
;; A Chez condition's message string (for Throwable .getMessage/.toString): the
;; &message text plus any &irritants, or display-condition output as a fallback.
(define (condition->message-string c)
(if (message-condition? c)
(let ((m (condition-message c))
(irr (if (irritants-condition? c) (condition-irritants c) '())))
(let loop ((xs irr) (acc m))
(if (null? xs) acc (loop (cdr xs) (string-append acc " " (jolt-pr-str (car xs)))))))
(with-output-to-string (lambda () (display-condition c)))))
(define (record-method-dispatch obj method-name rest-args)
(let ((rest (if (jolt-nil? rest-args) '() (seq->list rest-args))))
(cond
@ -266,6 +275,38 @@
(let ((v (jolt-first s))) (jiterator-cur-set! obj (jolt-rest s)) v))))
(else (error #f (string-append "No method " method-name " on Iterator")))))
((string=? method-name "iterator") (make-jiterator (jolt-seq obj)))
;; clojure.lang.Keyword interop: a Keyword carries an interned `sym` field
;; (the symbol form, ns + name) plus the Named methods. honeysql/reitit read
;; (.sym k) on their :clj branch to recover the symbol without the colon.
((keyword-t? obj)
(cond ((string=? method-name "sym")
(jolt-symbol (keyword-t-ns obj) (keyword-t-name obj)))
((string=? method-name "getName") (keyword-t-name obj))
((string=? method-name "getNamespace") (or (keyword-t-ns obj) jolt-nil))
((string=? method-name "toString")
(string-append ":" (if (keyword-t-ns obj) (string-append (keyword-t-ns obj) "/") "")
(keyword-t-name obj)))
((string=? method-name "hashCode") (keyword-t-khash obj))
((string=? method-name "equals") (and (pair? rest) (eq? obj (car rest))))
(else (error #f (string-append "No method " method-name " on Keyword")))))
;; clojure.lang.Symbol interop: the Named methods + getName/getNamespace.
((symbol-t? obj)
(cond ((string=? method-name "getName") (symbol-t-name obj))
((string=? method-name "getNamespace") (or (symbol-t-ns obj) jolt-nil))
((string=? method-name "toString")
(string-append (if (symbol-t-ns obj) (string-append (symbol-t-ns obj) "/") "")
(symbol-t-name obj)))
((string=? method-name "equals") (and (pair? rest) (jolt=2 obj (car rest))))
(else (error #f (string-append "No method " method-name " on Symbol")))))
;; java.lang.Throwable interop over a Chez condition. A jolt host error
;; (`error`/`assertion-violationf`) raises a Chez condition; Clojure code
;; that catches it as a Throwable reads (.getMessage e) / (.toString e).
((condition? obj)
(cond ((or (string=? method-name "getMessage") (string=? method-name "getLocalizedMessage"))
(condition->message-string obj))
((string=? method-name "toString") (condition->message-string obj))
((string=? method-name "getCause") jolt-nil)
(else (error #f (string-append "No method " method-name " on Throwable")))))
;; java.lang.Character interop: (.toString \+) -> "+", etc.
((char? obj)
(cond ((string=? method-name "toString") (string obj))

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -255,8 +255,11 @@
;; Return Scheme #t/#f (= jolt true/false). All-flonum model: coerce to an exact
;; integer for the parity tests.
;; ============================================================================
(define (jolt-even? n) (fx=? 0 (fxand (->idx n) 1)))
(define (jolt-odd? n) (fx=? 1 (fxand (->idx n) 1)))
;; Parity over the full integer range (JVM even?/odd? accept any integer,
;; bignums included); a fixnum-only fxand crashes on a large value (e.g. a hash).
(define (parity-int n) (if (flonum? n) (exact (floor n)) n))
(define (jolt-even? n) (even? (parity-int n)))
(define (jolt-odd? n) (odd? (parity-int n)))
(define (jolt-pos? n) (> n 0))
(define (jolt-neg? n) (< n 0))
(define (jolt-zero? n) (= n 0))

View file

@ -125,10 +125,29 @@
(def-var! "jdbc.core" "connection" (lambda (url) (sql-open (jdbc-strip-scheme url))))
(def-var! "jdbc.core" "execute!"
(lambda (c sqlvec) (let-values (((sql ps) (jdbc-sqlvec sqlvec))) (sql-query c sql ps) jolt-nil)))
;; jdbc.core (jolt-lang/db) returns kebab-cased column keys, so an `id_seq`
;; column reads as :id-seq — the Clojure jdbc convention the templates/queries
;; expect. (The lower jolt.sqlite/query keeps the raw column names.)
(define (kebab-keyword k)
(if (keyword-t? k)
(let ((nm (keyword-t-name k)))
(if (memv #\_ (string->list nm))
(keyword (keyword-t-ns k) (list->string (map (lambda (c) (if (char=? c #\_) #\- c)) (string->list nm))))
k))
k))
(define (kebab-row row)
(let loop ((s (jolt-seq row)) (m (jolt-hash-map)))
(if (jolt-nil? s) m
(let ((e (jolt-first s)))
(loop (jolt-seq (jolt-rest s)) (jolt-assoc m (kebab-keyword (jolt-nth e 0)) (jolt-nth e 1)))))))
(define (kebab-rows rows)
(let loop ((i 0) (out (jolt-vector)))
(if (>= i (pvec-count rows)) out
(loop (+ i 1) (jolt-conj out (kebab-row (pvec-nth-d rows i jolt-nil)))))))
(def-var! "jdbc.core" "fetch"
(lambda (c sqlvec) (let-values (((sql ps) (jdbc-sqlvec sqlvec))) (sql-query c sql ps))))
(lambda (c sqlvec) (let-values (((sql ps) (jdbc-sqlvec sqlvec))) (kebab-rows (sql-query c sql ps)))))
(def-var! "jdbc.core" "fetch-one"
(lambda (c sqlvec) (let-values (((sql ps) (jdbc-sqlvec sqlvec)))
(let ((rows (sql-query c sql ps)))
(if (> (pvec-count rows) 0) (pvec-nth-d rows 0 jolt-nil) jolt-nil)))))
(if (> (pvec-count rows) 0) (kebab-row (pvec-nth-d rows 0 jolt-nil)) jolt-nil)))))
(def-var! "jdbc.core" "last-insert-id" (lambda (c) (sqlite3_last_insert_rowid (sql-conn-db c))))

View file

@ -187,6 +187,30 @@
binds (str/join " " (map (fn [t a] (str "(" t " " a ")")) tmps arg-strs))]
(str "(let* (" binds ") (" ctor " " (str/join " " tmps) "))"))))
;; An operand whose evaluation has no observable effect and whose result doesn't
;; depend on when it runs: constants, locals, var/the-var reads, quoted literals.
;; Re-ordering such operands relative to others is invisible.
(defn- side-effect-free? [n]
(contains? #{:const :local :var :the-var :quote} (:op n)))
;; Clojure evaluates a call's operands (and recur's args) left to right; Chez's
;; application order is unspecified (right-to-left in practice). Force source
;; order by binding operands to fresh temps in a let* — but only when two or more
;; could have observable effects, so hot calls over locals/consts stay un-wrapped.
(defn- needs-order? [nodes]
(> (count (remove side-effect-free? nodes)) 1))
;; Build a call from operand strings, forcing left-to-right evaluation when
;; needed. `nodes`/`strs` are the operands (parallel); `build` receives the
;; operand strings to splice (temps when wrapped, raw otherwise) and returns the
;; call. Operands that don't need ordering are passed through inline.
(defn- ordered-call [nodes strs build]
(if (needs-order? nodes)
(let [tmps (mapv (fn [_] (fresh-label "_a$")) strs)
binds (str/join " " (map (fn [t a] (str "(" t " " a ")")) tmps strs))]
(str "(let* (" binds ") " (build tmps) ")"))
(build strs)))
;; Quoted literals (jolt-u8j7). A :quote node's :form is the RAW reader form;
;; reconstruct each as the matching Chez RT constructor — the runtime value of a
;; quote is just that literal data. The form is walked via the jolt.host form-*
@ -257,7 +281,9 @@
(defn- emit-recur [node]
(when-not *recur-target* (throw (ex-info "emit: recur outside a loop/fn target" {})))
(str "(" *recur-target* " " (str/join " " (map emit (:args node))) ")"))
(let [arg-nodes (:args node)]
(ordered-call arg-nodes (mapv emit arg-nodes)
(fn [as] (str "(" *recur-target* " " (str/join " " as) ")")))))
;; One arity -> a Scheme lambda param-list + a named-let-wrapped body. The named
;; let lets fn-level `recur` rebind this arity's params. A variadic arity takes a
@ -324,40 +350,55 @@
(defn- emit-invoke [node]
(let [fnode (:fn node)
args (mapv emit (:args node))
arg-nodes (:args node)
args (mapv emit arg-nodes)
nop (native-op fnode (count args))
kind (ifn-kind fnode)
default (if (> (count args) 1) (str " " (nth args 1)) "")]
;; order args left-to-right (build receives the spliced operand strings)
order-args (fn [build] (ordered-call arg-nodes args build))
defstr (fn [as] (if (> (count as) 1) (str " " (nth as 1)) ""))
;; jolt-invoke dispatch: Clojure evaluates the fn expr before the args, so
;; order [callee & args] together when ordering is observable.
invoke (fn []
(ordered-call (cons fnode arg-nodes) (cons (emit fnode) args)
(fn [[f & as]]
(str "(jolt-invoke " f (if (seq as) (str " " (str/join " " as)) "") ")"))))]
(cond
;; zero-arg + / * : exact integer identity (= JVM long: (+) -> 0, (*) -> 1).
(and nop (empty? args) (= nop "+")) "0"
(and nop (empty? args) (= nop "*")) "1"
(and nop (= 1 (count args)) (cmp1-ops nop)) (str "(begin " (first args) " #t)")
nop (str "(" nop " " (str/join " " args) ")")
;; (:k coll [default]) -> (jolt-get coll :k [default])
(= kind :keyword) (str "(jolt-get " (first args) " " (emit fnode) default ")")
;; (coll k [default]) -> (jolt-get coll k [default])
(= kind :coll) (str "(jolt-get " (emit fnode) " " (first args) default ")")
nop (order-args (fn [as] (str "(" nop " " (str/join " " as) ")")))
;; (:k coll [default]) -> (jolt-get coll :k [default]) — the key (fnode) is a
;; const, so only the coll/default args carry order.
(= kind :keyword)
(order-args (fn [as] (str "(jolt-get " (first as) " " (emit fnode) (defstr as) ")")))
;; (coll k [default]) -> (jolt-get coll k [default]) — coll (fnode) is the
;; callee, evaluated before the key/default args.
(= kind :coll)
(ordered-call (cons fnode arg-nodes) (cons (emit fnode) args)
(fn [[c & as]] (str "(jolt-get " c " " (str/join " " as) ")")))
(and (stdlib-var? fnode) (not (deref prelude-mode?)))
(throw (ex-info (str "emit: unsupported stdlib fn `" (:ns fnode) "/" (:name fnode)
"` (no core on Chez yet)") {}))
;; static method call (Class/method arg*) -> (host-static-call ...).
(= :host-static (:op fnode))
(order-args (fn [as]
(str "(host-static-call " (chez-str-lit (:class fnode)) " " (chez-str-lit (:member fnode))
(if (empty? args) "" (str " " (str/join " " args))) ")")
(if (empty? as) "" (str " " (str/join " " as))) ")")))
(= :host (:op fnode))
(throw (ex-info (str "emit: unsupported host call `" (:name fnode) "`") {}))
;; a :local callee that isn't a known procedure -> dynamic IFn dispatch.
(and (= :local (:op fnode)) (not (*known-procs* (munge-name (:name fnode)))))
(str "(jolt-invoke " (emit fnode) " " (str/join " " args) ")")
(invoke)
;; a late-bound :var call head can hold a procedure OR a non-applicable
;; value the RT dispatches (multimethod, keyword/coll IFn) — route via
;; jolt-invoke (transparent for a procedure).
(= :var (:op fnode))
(str "(jolt-invoke " (emit fnode) " " (str/join " " args) ")")
(invoke)
;; a computed callee can yield ANY IFn — route through jolt-invoke.
:else
(str "(jolt-invoke " (emit fnode) " " (str/join " " args) ")"))))
(invoke))))
;; try/catch/finally (jolt-vcsl). throw raises the jolt value RAW (jolt-throw =
;; Scheme `raise`); catch lowers to `guard` with an `else` clause (the IR drops