Refactor phase 1: consolidate host interop into one module (#107)

Cheap-version consolidation of the host-interop sprawl (jolt-jx5l). The JVM
class/method shims were scattered across javatime.janet, evaluator.janet,
api.janet and core.janet; the recent hiccup/markdown/malli fixes landed ad hoc.

- Rename javatime.janet -> host_interop.janet. The name described ~20% of its
  contents (java.io/util/net/sql/lang all lived there); it is now the one home
  for host shims, and greppable.
- Move the four hardcoded static tables (Math/Thread/System/Long) out of the
  evaluator and register them through the generic class-statics registry. The
  special-case dispatch in resolve-sym is deleted — one mechanism, not two.
- Move the collection-interop wiring (set-coll-realizer!, set-coll-interop!,
  malli's LazilyPersistentVector/PersistentArrayMap statics) from api.janet into
  host_interop's install-collections!. api's 40 lines of wiring become a single
  (import ./host_interop).

No behavior change. Full per-package src/jolt/interop/ split is the follow-up;
string/number/object method tables and core's File/JDBC ctors stay put for now
(they're coupled to the dot-dispatch / collection layer). Full gate green.

Co-authored-by: Yogthos <yogthos@gmail.com>
This commit is contained in:
Dmitri Sotnikov 2026-06-15 03:56:25 +00:00 committed by GitHub
parent 910c4b6c99
commit 62fe11fa0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 153 additions and 148 deletions

View file

@ -12,48 +12,9 @@
(import ./backend :as backend)
(import ./stdlib_embed :as stdlib-embed)
(import ./host_iface :as host)
(import ./javatime) # java.time shims register into the evaluator at load
(import ./phm :as mphm)
# Wire core's collection realizer into the evaluator's (.iterator coll) shim —
# late-bound here because the evaluator loads before core. Makes Java-Iterator-
# style loops (e.g. hiccup's iterate!) work over any jolt collection.
(set-coll-realizer! realize-for-iteration)
# clj-targeted libraries call JVM collection-builder statics in their :clj
# branches; malli's entry parser uses these. Map them to jolt's own builders.
# createOwning takes ownership of an object array -> a vector. createWithCheck
# builds a map and throws on duplicate keys (malli catches that to report them);
# detect a dup by the built map being smaller than the kv array implies.
(register-class-statics! "LazilyPersistentVector"
@{"createOwning" (fn [arr] (make-vec arr))})
(register-class-statics! "PersistentArrayMap"
@{"createWithCheck"
(fn [arr]
(def m (mphm/make-phm arr))
(if (= (* 2 (mphm/phm-count m)) (length arr)) m
(error "PersistentArrayMap: duplicate key")))})
# Java collection-interop methods on jolt persistent collections — clj-targeted
# libraries (malli) call .nth/.count/.valAt on vectors/maps in their :clj
# branches. Route to the clojure.core equivalent; :jolt/ci-none means "not mine".
(set-coll-interop!
(fn [target name args]
(if-not (or (pvec? target) (mphm/phm? target) (plist? target) (mphm/lazy-seq? target)
(and (table? target) (= :jolt/set (get target :jolt/type)))
(shape-rec? target) # map-as-tuple record
(and (struct? target) (nil? (get target :jolt/type)))) # plain map literal
:jolt/ci-none # only jolt persistent collections / maps — never records/reifies
(cond
(= name "nth") (if (>= (length args) 2) (core-nth target (in args 0) (in args 1))
(core-nth target (in args 0)))
(= name "count") (core-count target)
(or (= name "valAt") (= name "get"))
(if (>= (length args) 2) (core-get target (in args 0) (in args 1))
(core-get target (in args 0)))
(= name "seq") (core-seq target)
(= name "containsKey") (core-contains? target (in args 0))
:jolt/ci-none))))
# Host interop: JVM class/method shims + collection interop (.iterator/.nth/...)
# register into the evaluator at module load. See src/jolt/host_interop.janet.
(import ./host_interop)
# A defmacro expander compiles to a native fn (built as (fn args body...) and run
# through the self-hosted pipeline) so macro expansion is COMPILED code, zero runtime

View file

@ -1778,7 +1778,7 @@
# callbacks: :exec runs one SQL string in the transaction (so the janet-side
# Statement.executeBatch can run SQL without a janet->clj call), :close closes
# the underlying conn, :product is the JDBC database product name. instance?
# Connection (evaluator) and the :jolt/jdbc-conn tagged methods (javatime) key
# Connection (evaluator) and the :jolt/jdbc-conn tagged methods (host_interop) key
# off this shape.
(defn core-jdbc-wrap-conn [raw exec closef product]
@{:jolt/type :jolt/jdbc-conn :raw raw :exec exec :close closef
@ -1794,7 +1794,7 @@
# java.io.File model (jolt-hjw). io/file and (File. …) build a tagged :jolt/file
# value so (instance? File x) works and migratus's File-vs-jar branching takes
# the filesystem path. The File method surface + nio glob live in javatime; here
# the filesystem path. The File method surface + nio glob live in host_interop; here
# are the constructor/predicate builtins and the path coercion str/slurp use.
(defn core-file-path
"The path string of a :jolt/file, or (string x) for anything else."

View file

@ -534,90 +534,6 @@
(set t (table/getproto t)))
result)
(def- math-statics
@{"sqrt" math/sqrt "pow" math/pow "floor" math/floor "ceil" math/ceil
"abs" (fn [x] (if (< x 0) (- x) x))
"round" (fn [x] (math/round x))
"sin" math/sin "cos" math/cos "tan" math/tan
"asin" math/asin "acos" math/acos "atan" math/atan
"log" math/log "log10" math/log10 "exp" math/exp
"max" (fn [a b] (if (> a b) a b)) "min" (fn [a b] (if (< a b) a b))
"signum" (fn [x] (cond (< x 0) -1.0 (> x 0) 1.0 0.0))
"PI" math/pi "E" math/e
"random" (fn [&] (math/random))})
# Thread statics (the JVM shapes portable code actually uses). sleep parks the
# CURRENT thread's event loop — inside a future body that's the worker OS
# thread (ev/spawn-thread gives each worker its own loop), so a sleeping
# future doesn't block the parent.
(def- thread-statics
{"sleep" (fn [ms] (ev/sleep (/ ms 1000)) nil)
"yield" (fn [] (ev/sleep 0) nil)
"interrupted" (fn [] false)
"currentThread" (fn [] @{:jolt/type :jolt/thread :id "main"})})
# System statics (wall/monotonic clocks — what portable timing code uses).
(def- system-statics
# realtime clock (sub-ms float epoch seconds) — os/time is whole seconds,
# which quantized every elapsed-time measurement to 1000ms.
{"currentTimeMillis" (fn [] (math/floor (* 1000 (os/clock :realtime))))
"nanoTime" (fn [] (math/floor (* 1e9 (os/clock :monotonic))))
"getProperty" (fn [k &opt dflt]
(case k
"os.name" (case (os/which)
:windows "Windows" :macos "Mac OS X" "Linux")
"line.separator" "\n"
"file.separator" "/"
"user.dir" (os/cwd)
"user.home" (os/getenv "HOME")
"java.io.tmpdir" (or (os/getenv "TMPDIR") "/tmp")
dflt))
# JOLT_BAKE_ENV_ALLOWLIST (jolt-s3j): during an image bake (jpm build of a
# native executable, set by the project's build.sh) the env snapshot that
# libraries like config.core capture at load gets MARSHALED INTO THE BINARY
# — GitHub push protection once flagged real API tokens inside an example's
# build output. With the var set, System/getenv serves only the listed
# comma-separated names (single-var reads of unlisted names return nil), so
# nothing secret can bake. Unset (the normal runtime case), reads are live
# and unfiltered.
"getenv" (fn [&opt k]
(def allow (os/getenv "JOLT_BAKE_ENV_ALLOWLIST"))
(if (nil? allow)
(if k (os/getenv k) (os/environ))
(let [names (string/split "," allow)
ok @{}]
(each n names (put ok (string/trim n) true))
(if k
(when (get ok k) (os/getenv k))
(let [e (os/environ) out @{}]
(eachp [ek ev] e (when (get ok ek) (put out ek ev)))
out)))))
# the property subset getProperty serves, as an iterable map
"getProperties" (fn []
{"os.name" (case (os/which)
:windows "Windows" :macos "Mac OS X" "Linux")
"line.separator" "\n"
"file.separator" "/"
"user.dir" (os/cwd)
"user.home" (or (os/getenv "HOME") "")
"java.io.tmpdir" (or (os/getenv "TMPDIR") "/tmp")})})
# Long statics: sentinels portable code compares against. jolt numbers are
# doubles, so these are the f64 approximations.
(def- long-statics
{"MAX_VALUE" 9223372036854775807
"MIN_VALUE" -9223372036854775808
"parseLong" (fn [s &opt radix]
(def n (scan-number (string/trim (string s)) (or radix 10)))
(if (and n (= n (math/floor n)))
n
(error (string "NumberFormatException: For input string: \"" s "\""))))
"valueOf" (fn [s &opt radix]
(def n (scan-number (string/trim (string s)) (or radix 10)))
(if (and n (= n (math/floor n)))
n
(error (string "NumberFormatException: For input string: \"" s "\""))))})
# Pluggable host-class shims (java.time etc. register here at module load):
# class-statics: "ClassName" -> {"member" value-or-fn} (Foo/bar resolution)
# tagged-methods: :jolt/tag -> {"method" (fn [self args...])} ((.m obj) dispatch)
@ -777,18 +693,8 @@
(defn- resolve-sym
[ctx bindings sym-s]
(let [name (sym-s :name) ns (sym-s :ns)]
(if (= ns "Math")
(let [v (get math-statics name)]
(if (nil? v) (error (string "Unsupported Math member: Math/" name)) v))
(if (= ns "Thread")
(let [v (get thread-statics name)]
(if (nil? v) (error (string "Unsupported Thread member: Thread/" name)) v))
(if (= ns "System")
(let [v (get system-statics name)]
(if (nil? v) (error (string "Unsupported System member: System/" name)) v))
(if (= ns "Long")
(let [v (get long-statics name)]
(if (nil? v) (error (string "Unsupported Long member: Long/" name)) v))
# Math/Thread/System/Long and every other class resolve through the generic
# class-statics registry (host_interop registers them at load); no special-case.
(if (get class-statics ns)
(let [v (get (get class-statics ns) name)]
(if (nil? v) (error (string "Unsupported member: " ns "/" name)) v))
@ -859,7 +765,7 @@
# janet/ prefix above.
(if (or (in class-ctors name) (get class-canonical-names name) (get class-value-overrides name))
(class-value-for name)
(error (string "Unable to resolve symbol: " name " in this context")))))))))))))))))))
(error (string "Unable to resolve symbol: " name " in this context")))))))))))))))
(defn- parse-arg-names
"Parse a parameter vector, handling & rest args.
Returns {:fixed [names...] :rest name-or-nil :all [names...]}"
@ -1778,7 +1684,7 @@
"java.util.regex.Pattern" (and (table? val) (= :jolt/regex (val :jolt/type)))
"Character" (and (struct? val) (= :jolt/char (get val :jolt/type)))
"java.lang.Character" (and (struct? val) (= :jolt/char (get val :jolt/type)))
# java.time shims (javatime.janet); #inst IS java.util.Date in Clojure
# java.time shims (host_interop.janet); #inst IS java.util.Date in Clojure
"java.util.Date" (and (struct? val) (= :jolt/inst (get val :jolt/type)))
"Date" (and (struct? val) (= :jolt/inst (get val :jolt/type)))
"Instant" (and (table? val) (= :jolt/instant (get val :jolt/type)))

View file

@ -1,16 +1,110 @@
# java.time shims (jolt-ea7): the surface Selmer's date filters use, backed
# by epoch milliseconds (the same representation as :jolt/inst). Local time
# means the HOST's local time (os/date with local=true); zones beyond the
# system default are not modeled. Registered through the evaluator's
# class-statics / tagged-methods registries, so this module is data plus an
# install call — adding another java.* shim follows the same shape.
# Host interop: the JVM class/method surface that portable cljc libraries call,
# emulated over jolt values — java.time, java.io, java.util, java.net, java.lang
# (Math/System/Thread/Long/...), java.sql and a few clojure.lang shims, plus the
# wiring that lets Java-style collection interop (.iterator/.nth/.count) and
# malli's collection-builder statics work over jolt collections.
#
# Everything registers through the evaluator's class-statics / tagged-methods /
# class-ctors registries, so each shim is data + an install call: adding a
# `(SomeClass. ...)` ctor or a `.someMethod` follows the same shape. This is the
# one home for host shims (jolt-jx5l); the registry machinery stays in evaluator.
(use ./evaluator)
(use ./regex)
(use ./core)
(use ./pv)
(use ./plist)
(use ./types)
(import ./phm)
(defn- chr [s] (get s 0))
# --- java.lang static surfaces (Math/Thread/System/Long) ----------------------
# Registered through the generic class-statics registry below, same as every
# other class — there is no special-case dispatch (jolt-jx5l).
(def- math-statics
@{"sqrt" math/sqrt "pow" math/pow "floor" math/floor "ceil" math/ceil
"abs" (fn [x] (if (< x 0) (- x) x))
"round" (fn [x] (math/round x))
"sin" math/sin "cos" math/cos "tan" math/tan
"asin" math/asin "acos" math/acos "atan" math/atan
"log" math/log "log10" math/log10 "exp" math/exp
"max" (fn [a b] (if (> a b) a b)) "min" (fn [a b] (if (< a b) a b))
"signum" (fn [x] (cond (< x 0) -1.0 (> x 0) 1.0 0.0))
"PI" math/pi "E" math/e
"random" (fn [&] (math/random))})
# sleep parks the CURRENT thread's event loop — inside a future body that's the
# worker OS thread (ev/spawn-thread gives each worker its own loop), so a
# sleeping future doesn't block the parent.
(def- thread-statics
{"sleep" (fn [ms] (ev/sleep (/ ms 1000)) nil)
"yield" (fn [] (ev/sleep 0) nil)
"interrupted" (fn [] false)
"currentThread" (fn [] @{:jolt/type :jolt/thread :id "main"})})
# wall/monotonic clocks + properties/env (what portable timing/config code uses).
(def- system-statics
# realtime clock (sub-ms float epoch seconds) — os/time is whole seconds,
# which quantized every elapsed-time measurement to 1000ms.
{"currentTimeMillis" (fn [] (math/floor (* 1000 (os/clock :realtime))))
"nanoTime" (fn [] (math/floor (* 1e9 (os/clock :monotonic))))
"getProperty" (fn [k &opt dflt]
(case k
"os.name" (case (os/which)
:windows "Windows" :macos "Mac OS X" "Linux")
"line.separator" "\n"
"file.separator" "/"
"user.dir" (os/cwd)
"user.home" (os/getenv "HOME")
"java.io.tmpdir" (or (os/getenv "TMPDIR") "/tmp")
dflt))
# JOLT_BAKE_ENV_ALLOWLIST (jolt-s3j): during an image bake (jpm build of a
# native executable, set by the project's build.sh) the env snapshot that
# libraries like config.core capture at load gets MARSHALED INTO THE BINARY
# — GitHub push protection once flagged real API tokens inside an example's
# build output. With the var set, System/getenv serves only the listed
# comma-separated names (single-var reads of unlisted names return nil), so
# nothing secret can bake. Unset (the normal runtime case), reads are live
# and unfiltered.
"getenv" (fn [&opt k]
(def allow (os/getenv "JOLT_BAKE_ENV_ALLOWLIST"))
(if (nil? allow)
(if k (os/getenv k) (os/environ))
(let [names (string/split "," allow)
ok @{}]
(each n names (put ok (string/trim n) true))
(if k
(when (get ok k) (os/getenv k))
(let [e (os/environ) out @{}]
(eachp [ek ev] e (when (get ok ek) (put out ek ev)))
out)))))
# the property subset getProperty serves, as an iterable map
"getProperties" (fn []
{"os.name" (case (os/which)
:windows "Windows" :macos "Mac OS X" "Linux")
"line.separator" "\n"
"file.separator" "/"
"user.dir" (os/cwd)
"user.home" (or (os/getenv "HOME") "")
"java.io.tmpdir" (or (os/getenv "TMPDIR") "/tmp")})})
# sentinels portable code compares against. jolt numbers are doubles, so these
# are the f64 approximations.
(def- long-statics
{"MAX_VALUE" 9223372036854775807
"MIN_VALUE" -9223372036854775808
"parseLong" (fn [s &opt radix]
(def n (scan-number (string/trim (string s)) (or radix 10)))
(if (and n (= n (math/floor n)))
n
(error (string "NumberFormatException: For input string: \"" s "\""))))
"valueOf" (fn [s &opt radix]
(def n (scan-number (string/trim (string s)) (or radix 10)))
(if (and n (= n (math/floor n)))
n
(error (string "NumberFormatException: For input string: \"" s "\""))))})
# --- values -------------------------------------------------------------------
(defn- instant [ms] @{:jolt/type :jolt/instant :ms ms})
@ -112,6 +206,11 @@
# --- registration --------------------------------------------------------------
(defn install! []
# java.lang statics through the generic registry (no resolve-sym special-case).
(register-class-statics! "Math" math-statics)
(register-class-statics! "Thread" thread-statics)
(register-class-statics! "System" system-statics)
(register-class-statics! "Long" long-statics)
(def fs (fn [style] @{:jolt/type :jolt/format-style :style style}))
(register-class-statics! "FormatStyle"
@{"SHORT" (fs :short) "MEDIUM" (fs :medium) "LONG" (fs :long) "FULL" (fs :full)})
@ -670,5 +769,44 @@
(register-tagged-methods! :jolt/nio-matcher
@{"matches" (fn [self path] (glob-matches? (get self :glob) (get path :s)))}))
# Collection interop: wires the evaluator's late-bound hooks to core's
# collection ops so Java-style interop works over jolt values (moved here from
# api — jolt-jx5l). Late-bound because the evaluator loads before core.
(defn install-collections! []
# (.iterator coll) -> a jolt iterator over any seqable (hiccup's iterate!).
(set-coll-realizer! realize-for-iteration)
# clj-targeted libs build collections via JVM statics in their :clj branches;
# malli's entry parser uses these. createOwning takes ownership of an object
# array -> a vector; createWithCheck builds a map and throws on duplicate keys
# (malli catches that), detected by the built map being smaller than the kvs.
(register-class-statics! "LazilyPersistentVector"
@{"createOwning" (fn [arr] (make-vec arr))})
(register-class-statics! "PersistentArrayMap"
@{"createWithCheck"
(fn [arr]
(def m (phm/make-phm arr))
(if (= (* 2 (phm/phm-count m)) (length arr)) m
(error "PersistentArrayMap: duplicate key")))})
# .nth/.count/.valAt/.get/.seq/.containsKey on a jolt collection -> the
# clojure.core equivalent. :jolt/ci-none means "not a collection method here".
(set-coll-interop!
(fn [target name args]
(if-not (or (pvec? target) (phm/phm? target) (plist? target) (phm/lazy-seq? target)
(and (table? target) (= :jolt/set (get target :jolt/type)))
(shape-rec? target) # map-as-tuple record
(and (struct? target) (nil? (get target :jolt/type)))) # plain map literal
:jolt/ci-none
(cond
(= name "nth") (if (>= (length args) 2) (core-nth target (in args 0) (in args 1))
(core-nth target (in args 0)))
(= name "count") (core-count target)
(or (= name "valAt") (= name "get"))
(if (>= (length args) 2) (core-get target (in args 0) (in args 1))
(core-get target (in args 0)))
(= name "seq") (core-seq target)
(= name "containsKey") (core-contains? target (in args 0))
:jolt/ci-none)))))
(install!)
(install-io!)
(install-collections!)