Merge pull request #279 from jolt-lang/data-reader-code-forms

Compile data readers that return code forms
This commit is contained in:
Dmitri Sotnikov 2026-07-01 15:03:43 +00:00 committed by GitHub
commit 0bad467372
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 88 additions and 8 deletions

View file

@ -116,4 +116,17 @@ fi
if grep -q 'def-var! "clojure.core" "group-by"' "$out.build/flat.ss"; then if grep -q 'def-var! "clojure.core" "group-by"' "$out.build/flat.ss"; then
echo " FAIL: --tree-shake kept an unreachable clojure.core fn (group-by)"; exit 1 echo " FAIL: --tree-shake kept an unreachable clojure.core fn (group-by)"; exit 1
fi fi
echo "build smoke: passed (release + optimized + direct-link + tree-shake + compiler+core shake)" # A registered data reader that returns a CODE form must be compiled into the
# binary (the emit path applies it too, not just the interpreted loader): the
# datareader-app's #code literal builds to 42, not the literal list.
drapp="$root/test/chez/datareader-app"
drout="$(dirname "$out")/dr-bin"
if ! JOLT_PWD="$drapp" bin/joltc build -m drtest.main -o "$drout" >/dev/null 2>&1; then
echo " FAIL: jolt build of a data-reader app exited non-zero"; exit 1
fi
got_dr="$(cd / && "$drout" 2>&1 | tail -1)"
if [ "$got_dr" != "42" ]; then
echo " FAIL: built #code data reader — want 42, got \`$got_dr\`"; exit 1
fi
echo "build smoke: passed (release + optimized + direct-link + tree-shake + compiler+core shake + data-reader)"

View file

@ -449,6 +449,11 @@
;; whole-program param-type fixpoint before per-form emit ;; whole-program param-type fixpoint before per-form emit
(when (string=? mode "optimized") (bld-wp-infer! ordered))) (when (string=? mode "optimized") (bld-wp-infer! ordered)))
(lambda () (lambda ()
;; A #tag data-reader literal must compile in the binary the same as
;; it loads interpreted — apply the reader rewrite to each emitted
;; form too (no-op unless the app registered data readers).
(parameterize ((ei-emit-form-hook
(lambda (form) (if data-readers-active (ldr-apply-readers form) form))))
(if tree-shake? (if tree-shake?
(dce-shake (dce-shake
(dce-blob-records "host/chez/seed/prelude.ss") (dce-blob-records "host/chez/seed/prelude.ss")
@ -473,7 +478,7 @@
(append (bld-ns-prelude (car nf) src) (append (bld-ns-prelude (car nf) src)
(bld-emit-ns (car nf) src))))) (bld-emit-ns (car nf) src)))))
ordered)) ordered))
#f))) #f))))
(lambda () (lambda ()
(set-optimize! #f) (set-optimize! #f)
((var-deref "jolt.backend-scheme" "set-direct-link!") #f))))) ((var-deref "jolt.backend-scheme" "set-direct-link!") #f)))))

View file

@ -63,15 +63,23 @@
;; the seed minter (ei-emit-ns: optimize? #f, guard? #t — tolerant, skips a form ;; the seed minter (ei-emit-ns: optimize? #f, guard? #t — tolerant, skips a form
;; that fails to emit) and `jolt build` (bld-emit-ns: optimize? #t, guard? #f — ;; that fails to emit) and `jolt build` (bld-emit-ns: optimize? #t, guard? #f —
;; strict, a failing form errors the build). ;; strict, a failing form errors the build).
;; A per-form transform applied to each read form before emit — the build sets it
;; to the data-reader rewrite (loader.ss ldr-apply-readers) so a registered #tag
;; literal compiles in a `jolt build` the same as it does in an interpreted load.
;; #f (the default, and during the seed mint where loader.ss isn't loaded) is no
;; transform, so emit-image.ss carries no loader dependency.
(define ei-emit-form-hook (make-parameter #f))
(define (ei-emit-ns* ns-name src optimize? guard?) (define (ei-emit-ns* ns-name src optimize? guard?)
;; set the ns before reading so ::kw auto-resolves against this ns (the runtime ;; set the ns before reading so ::kw auto-resolves against this ns (the runtime
;; loader reads form-by-form after the ns form sets it; the cross-compile reads ;; loader reads form-by-form after the ns form sets it; the cross-compile reads
;; all forms up front, so set it here). ;; all forms up front, so set it here).
(set-chez-ns! ns-name) (set-chez-ns! ns-name)
(let loop ((forms (ei-read-all src)) (acc '())) (let ((hook (ei-emit-form-hook)))
(let loop ((forms (ei-read-all src)) (acc '()))
(if (null? forms) (if (null? forms)
(reverse acc) (reverse acc)
(let ((f (car forms))) (let ((f (let ((f0 (car forms))) (if hook (hook f0) f0))))
(ce-scan-requires! f ns-name) (ce-scan-requires! f ns-name)
(cond (cond
((ei-ns-form? f) (loop (cdr forms) acc)) ((ei-ns-form? f) (loop (cdr forms) acc))
@ -89,7 +97,7 @@
(ei-compile-form (make-analyze-ctx ns-name) f optimize?)))) (ei-compile-form (make-analyze-ctx ns-name) f optimize?))))
(loop (cdr forms) (loop (cdr forms)
(if (and guard? (not scm)) acc (if (and guard? (not scm)) acc
(cons (if guard? (string-append "(guard (e (#t #f))\n " scm ")") scm) acc)))))))))) (cons (if guard? (string-append "(guard (e (#t #f))\n " scm ")") scm) acc)))))))))))
(define (ei-emit-ns ns-name src) (ei-emit-ns* ns-name src #f #t)) (define (ei-emit-ns ns-name src) (ei-emit-ns* ns-name src #f #t))

View file

@ -57,9 +57,25 @@
((and (pmap? x) (eq? (jolt-get x rdr-kw-jolt-type) rdr-kw-jolt-tagged)) ((and (pmap? x) (eq? (jolt-get x rdr-kw-jolt-type) rdr-kw-jolt-tagged))
(let ((rdr (data-reader-symbol (jolt-get x rdr-kw-tag))) (let ((rdr (data-reader-symbol (jolt-get x rdr-kw-tag)))
(inner (ldr-apply-readers (jolt-get x rdr-kw-form)))) (inner (ldr-apply-readers (jolt-get x rdr-kw-form))))
(cond (rdr (jolt-list rdr (jolt-list (jolt-symbol #f "quote") inner))) (cond
((eq? inner (jolt-get x rdr-kw-form)) x) (rdr
(else (rdr-make-tagged (jolt-get x rdr-kw-tag) inner))))) ;; Clojure applies a data reader at read time and substitutes its result
;; as code. A reader that returns a FORM (a list — e.g. borkdude.html's
;; #html expands to (->Html (str …))) must be compiled, so splice it in.
;; A reader that returns a VALUE (time-literals #time/date -> a Date) is
;; left as a runtime call (reader-fn 'inner): the value rebuilds at
;; startup, which also keeps a non-serializable constant out of an AOT
;; build. Apply is guarded — a reader that can't run at load time (its
;; deps not ready) falls back to the runtime call too.
(let ((result (and (symbol-t? rdr) (not (jolt-nil? (symbol-t-ns rdr)))
(guard (e (#t #f))
(let ((fn (var-deref (symbol-t-ns rdr) (symbol-t-name rdr))))
(and (procedure? fn) (jolt-invoke fn inner)))))))
(if (cseq? result)
result
(jolt-list rdr (jolt-list (jolt-symbol #f "quote") inner)))))
((eq? inner (jolt-get x rdr-kw-form)) x)
(else (rdr-make-tagged (jolt-get x rdr-kw-tag) inner)))))
((rdr-set-form? x) ((rdr-set-form? x)
(let-values (((items changed) (ldr-conv-each (seq->list (jolt-get x rdr-kw-value))))) (let-values (((items changed) (ldr-conv-each (seq->list (jolt-get x rdr-kw-value)))))
(if changed (rdr-carry-meta x (rdr-make-set items)) x))) (if changed (rdr-carry-meta x (rdr-make-set items)) x)))

View file

@ -75,6 +75,18 @@ else
fails=$((fails + 1)) fails=$((fails + 1))
fi fi
# A data reader that returns a CODE form (deps.edn data_readers.clj -> reader fn)
# must have its result spliced in and COMPILED, like Clojure — #code [:x] becomes
# (+ 40 2) and evaluates to 42, not the literal list. A project run so the source
# root's data_readers.clj is picked up.
dr_out="$(JOLT_PWD="$root/test/chez/datareader-app" bin/joltc run -m drtest.main 2>/dev/null | tail -1)"
if [ "$dr_out" = "42" ]; then
pass=$((pass + 1))
else
echo " FAIL: code-returning data reader (#code) not compiled — got \`$dr_out\`, want 42"
fails=$((fails + 1))
fi
# Unit-checks the REPL read-until-complete predicate over balanced/unbalanced, # Unit-checks the REPL read-until-complete predicate over balanced/unbalanced,
# string, comment and regex-literal inputs. A multi-form `joltc run` so jolt.main # string, comment and regex-literal inputs. A multi-form `joltc run` so jolt.main
# is loaded and its private var resolves; the file self-checks and prints a sentinel. # is loaded and its private var resolves; the file self-checks and prints a sentinel.

View file

@ -102,6 +102,12 @@
(clojure.test/do-report {:type :error :message ~msg :form '~form (clojure.test/do-report {:type :error :message ~msg :form '~form
:actual (clojure.test/err-text e#)})))) :actual (clojure.test/err-text e#)}))))
;; The common pure predicates whose args `is` evaluates so a failure shows the
;; actual values — (is (= expected got)) prints `got`, not just the form. A macro
;; head (not in this set) keeps the plain form-only path.
(def ^:private reported-preds
'#{= not= == < > <= >= identical? contains? instance? nil? some? empty? even? odd? pos? neg? zero?})
;; --- class matching for thrown? -------------------------------------------- ;; --- class matching for thrown? --------------------------------------------
(defn- last-seg [s] (defn- last-seg [s]
@ -173,6 +179,18 @@
(contains? (methods clojure.test/assert-expr) (first form))) (contains? (methods clojure.test/assert-expr) (first form)))
(clojure.test/assert-expr msg form) (clojure.test/assert-expr msg form)
;; a predicate call — (= a b), (< x y), (pred? v): evaluate the args so a
;; failure shows the actual values, like clojure.test's assert-predicate.
(and (seq? form) (contains? clojure.test/reported-preds (first form)))
`(try
(let [vs# (list ~@(rest form))]
(if (apply ~(first form) vs#)
(clojure.test/inc-pass!)
(clojure.test/fail! (str (pr-str (list '~'not (cons '~(first form) vs#)))
(when ~msg (str " — " ~msg))))))
(catch Throwable e#
(clojure.test/err! (str (pr-str '~form) " threw: " (clojure.test/err-text e#)))))
:else :else
`(try `(try
(if ~form (if ~form

View file

@ -0,0 +1 @@
{:paths ["src"]}

View file

@ -0,0 +1 @@
{code drtest.reader/code-reader}

View file

@ -0,0 +1,4 @@
(ns drtest.main
(:require drtest.reader))
(defn -main [& _]
(println #code [:ignored]))

View file

@ -0,0 +1,2 @@
(ns drtest.reader)
(defn code-reader [_form] (list '+ 40 2))