feat: java.io.File model + multimethod/assoc/defmulti fixes for migratus (jolt-hjw)

File API (jolt-hjw): io/file and (File. …) build a tagged :jolt/file value
(instance? File true) with a full method surface (isFile/isDirectory/exists/
getName/getPath/getAbsolutePath/listFiles/toPath/delete/createNewFile/…) backed
by os/ and file/. file-seq is File-aware (leaves are File values). str/slurp/spit
coerce :jolt/file to its path. ClassLoader/getSystemClassLoader + a classloader
stub whose getResource returns nil degrade migratus's classpath lookup to the
filesystem. java.nio.file Path/FileSystem/PathMatcher are shimmed just enough for
script-excluded?'s glob (recursive * / ? matcher).

Three bugs found getting migratus's migration discovery to work:
- (assoc nil k v) returned a raw janet table, not a map, so assoc-in built tables
  that count/seq rejected. Now returns an immutable map.
- methods/get-method resolved the multimethod symbol at runtime in the current
  ns, so a bare multifn ref in its defining ns saw an empty table once defmethods
  lived elsewhere. Now they take the multimethod VALUE and recover the var via a
  registry (Clojure semantics).
- defmulti now drops a leading docstring/attr-map (migratus's multimethods carry
  docstrings) instead of treating the docstring as the dispatch fn.

Conformance 335/335 x3, clojure-test-suite at baseline.
This commit is contained in:
Yogthos 2026-06-13 16:04:30 -04:00
parent 9813186ef9
commit b304c43333
6 changed files with 164 additions and 19 deletions

View file

@ -519,7 +519,11 @@
;; via the host dir primitives. Paths (strings), not File objects. (Lives below
;; tree-seq: forward references are analysis errors now — jolt-2o7.3.)
(defn file-seq [root]
(tree-seq __dir? __list-dir root))
(if (__file? root)
;; java.io.File tree: walk via the File method surface so leaves are File
;; values callers can invoke .isFile/.getName/slurp on (jolt-hjw).
(tree-seq (fn [f] (.isDirectory f)) (fn [f] (seq (.listFiles f))) root)
(tree-seq __dir? __list-dir root)))
;; Canonical flatten via tree-seq: the leaves (non-sequential nodes) in order.
;; Flattens lists too (sequential?), matching Clojure/CLJS.

View file

@ -21,8 +21,15 @@
;; clojure.core fns) so they compile as plain invokes. name/mm are passed quoted;
;; the dispatch fn, options, and dispatch value evaluate normally, and the method
;; body becomes a compiled (fn …).
(defmacro defmulti [name dispatch & opts]
`(defmulti-setup (quote ~name) ~dispatch ~@opts))
;; Clojure allows (defmulti name docstring? attr-map? dispatch-fn & options);
;; drop a leading docstring and/or attr-map so the dispatch fn isn't mistaken for
;; one (migratus's multimethods carry docstrings).
(defmacro defmulti [name & args]
(let [args (if (string? (first args)) (rest args) args)
args (if (and (map? (first args)) (not (symbol? (first args)))) (rest args) args)
dispatch (first args)
opts (rest args)]
`(defmulti-setup (quote ~name) ~dispatch ~@opts)))
(defmacro defmethod [mm dispatch-val & fn-tail]
`(defmethod-setup (quote ~mm) ~dispatch-val (fn ~@fn-tail)))
@ -39,11 +46,14 @@
(defmacro remove-all-methods [mm]
`(remove-all-methods-setup (quote ~mm)))
;; methods/get-method take the multimethod VALUE (Clojure semantics); the setup
;; maps it back to its var via the registry, so a bare multifn ref works from a
;; compiled fn in any namespace (jolt multimethod table-visibility fix).
(defmacro get-method [mm dval]
`(get-method-setup (quote ~mm) ~dval))
`(get-method-setup ~mm ~dval))
(defmacro methods [mm]
`(methods-setup (quote ~mm)))
`(methods-setup ~mm))
;; prefers reads the store off the VAR (the multifn value can't carry it) —
;; same symbol-passing shape as the other multimethod table ops.

View file

@ -6,11 +6,13 @@
; …) won't work, but file/reader/writer/resource/copy/slurp do.
(defn file
"A file path. With a parent and child, joins them with '/'."
([path] (str path))
([parent child] (str parent "/" child)))
"A java.io.File. With a parent and child, joins them with '/'. Returns a
:jolt/file value (instance? File true) with the File method surface; str/slurp
and io/reader coerce it back to its path."
([path] (__make-file path))
([parent child] (__make-file parent child)))
(defn as-file [x] (str x))
(defn as-file [x] (if (__file? x) x (__make-file x)))
(defn reader [x]
(cond

View file

@ -541,7 +541,10 @@
result)
(do (var result @{}) (when m (each k (keys m) (put result k (get m k))))
(var i 0) (while (< i (length kvs)) (let [k (kvs i) v (kvs (+ i 1))] (put result k v) (+= i 2)))
(if (struct? m) (table/to-struct result) result))))))
# nil assocs to a fresh immutable map ((assoc nil :a 1) => {:a 1}); a
# raw table here would not count?/seq like a Clojure map (assoc-in into
# an absent key recurses through nil — migratus's migration maps).
(if (or (struct? m) (nil? m)) (table/to-struct result) result))))))
(defn core-dissoc [m & ks]
(cond
@ -1656,6 +1659,8 @@
(if (v :ns) (string (v :ns) "/" (v :name)) (v :name))
(and (struct? v) (= :jolt/uuid (v :jolt/type))) (v :str)
(and (struct? v) (= :jolt/inst (v :jolt/type))) (inst->rfc3339 v)
# a java.io.File renders as its path (Clojure's File.toString)
(and (table? v) (= :jolt/file (get v :jolt/type))) (get v :path)
(= :jolt/namespace (get v :jolt/type)) (ns-display-name v)
(and (table? v) (= :jolt/var (get v :jolt/type))) (var-display v)
(number? v) (fmt-number v)
@ -1744,6 +1749,19 @@
(defn core-jdbc-make-stmt [w]
@{:jolt/type :jolt/jdbc-stmt :exec (get w :exec) :cmds @[]})
# 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
# 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."
[x]
(if (and (table? x) (= :jolt/file (get x :jolt/type))) (get x :path) (string x)))
(defn core-make-file [path &opt child]
(def base (core-file-path path))
@{:jolt/type :jolt/file :path (if child (string base "/" (core-file-path child)) base)})
(defn core-file? [x] (and (table? x) (= :jolt/file (get x :jolt/type))))
# newline lives in the Clojure collection tier (core/20-coll.clj).
# Clojure 1.11 string->scalar parsers: nil on malformed input, throw on a
@ -1787,6 +1805,7 @@
# bodies, and the jolt Ring adapter hands those over as StringReaders.
(defn core-slurp [src & opts]
(cond
(core-file? src) (string (slurp (core-file-path src)))
(and (table? src) (string? (get src :s)) (number? (get src :pos)))
(let [s (src :s) p (src :pos)]
(put src :pos (length s))
@ -1799,7 +1818,7 @@
(when (and (= :append (in opts i)) (in opts (+ i 1))) (set a true))
(+= i 2))
a))
(def f (file/open path (if append? :a :w)))
(def f (file/open (core-file-path path) (if append? :a :w)))
(file/write f (str-render-one content))
(file/close f)
nil)
@ -2731,6 +2750,8 @@
"__jdbc-wrap-conn" core-jdbc-wrap-conn
"__jdbc-conn-raw" core-jdbc-conn-raw
"__jdbc-make-stmt" core-jdbc-make-stmt
"__make-file" core-make-file
"__file?" core-file?
"__pr-str1" core-pr-str1
"__make-uuid" make-uuid
"compare" core-compare

View file

@ -1197,6 +1197,13 @@
(ns-unmap ns (if (and (struct? sym) (= :symbol (sym :jolt/type))) (sym :name) (string sym))))))
nil)
# Multimethod value -> its var. methods/get-method take the multimethod VALUE
# (Clojure semantics) and recover the var (hence :jolt/methods) through this,
# which works from a compiled fn in any namespace — resolving the symbol at call
# time in the current ns did not (a bare multifn ref in its defining ns saw an
# empty table once defmethods lived in other namespaces; migratus hit this).
(def multi-registry @{})
(defn defmulti-setup
"(defmulti name dispatch & opts) — intern a multimethod var. A fn; name arrives
quoted, dispatch + opts (:default key, :hierarchy h) arrive evaluated. The
@ -1292,6 +1299,7 @@
(put v :jolt/dispatch-cache dispatch-cache)
(put v :jolt/default default-key)
(when hierarchy (put v :jolt/hierarchy hierarchy))
(put multi-registry mm-fn v)
(var-get v))
(defn defmethod-setup
@ -1301,9 +1309,11 @@
[ctx mm-sym dispatch-val impl]
(def mm-var
(or (resolve-var ctx @{} mm-sym)
(let [ns (ctx-find-ns ctx (ctx-current-ns ctx))]
(def v (ns-intern ns (mm-sym :name) (fn [& args] nil)))
(let [ns (ctx-find-ns ctx (ctx-current-ns ctx))
stub (fn [& args] nil)]
(def v (ns-intern ns (mm-sym :name) stub))
(put v :jolt/methods @{})
(put multi-registry stub v)
v)))
(def methods (or (get mm-var :jolt/methods) (let [m @{}] (put mm-var :jolt/methods m) m)))
# nil is a legal dispatch value (ring's body-string keys a method on it);
@ -1470,8 +1480,10 @@
found
(when auto-create?
(def ns (ctx-find-ns ctx (ctx-current-ns ctx)))
(def nv (ns-intern ns (mm-sym :name) (fn [& args] nil)))
(def stub (fn [& args] nil))
(def nv (ns-intern ns (mm-sym :name) stub))
(put nv :jolt/methods @{})
(put multi-registry stub nv)
nv))))
(def clear-dispatch-cache! (fn [mm-var]
(let [dc (get mm-var :jolt/dispatch-cache)]
@ -1510,16 +1522,21 @@
(fn [mm-sym]
(def mm-var (mm-var-of mm-sym false))
(or (and mm-var (get mm-var :jolt/prefers)) {})))
# methods/get-method receive the multimethod VALUE (Clojure semantics): map it
# back to its var via multi-registry. A symbol arg still works (mm-var-of), for
# any caller that passes one.
(def mm-var-of-val (fn [mm]
(if (function? mm) (get multi-registry mm) (mm-var-of mm false))))
(ns-intern core "get-method-setup"
(fn [mm-sym dval]
(fn [mm dval]
(def dval (if (nil? dval) :jolt/nil-sentinel dval))
(def mm-var (mm-var-of mm-sym false))
(def mm-var (mm-var-of-val mm))
(when mm-var
(let [methods (get mm-var :jolt/methods)]
(or (get methods dval) (get methods :default))))))
(ns-intern core "methods-setup"
(fn [mm-sym]
(def mm-var (mm-var-of mm-sym false))
(fn [mm]
(def mm-var (mm-var-of-val mm))
(when mm-var
# a jolt map, not the live host table (and phm so vector dispatch
# values look up by value, same reason build-eval-map promotes)
@ -1588,6 +1605,10 @@
# branch, so the wrapped conn must answer true here.
"Connection" (and (table? val) (= :jolt/jdbc-conn (get val :jolt/type)))
"java.sql.Connection" (and (table? val) (= :jolt/jdbc-conn (get val :jolt/type)))
# java.io.File model (jolt-hjw): io/file and (File. …) build :jolt/file,
# so migratus's (instance? File migration-dir) takes the filesystem path.
"File" (and (table? val) (= :jolt/file (get val :jolt/type)))
"java.io.File" (and (table? val) (= :jolt/file (get val :jolt/type)))
# JVM char[] class — (Class/forName "[C"); jolt char arrays are Janet
# arrays of char structs
"[C" (and (array? val)

View file

@ -537,6 +537,15 @@
# that walks Thread/currentThread doesn't crash.
(register-tagged-methods! :jolt/thread
@{"getContextClassLoader" (fn [self] @{:jolt/type :jolt/classloader})})
# ClassLoader degrade (jolt-hjw): there is no classpath, so getResource returns
# nil and migratus's find-migration-dir falls through to the filesystem branch
# (resources/<dir>). getSystemClassLoader yields the same stub.
(each nm ["ClassLoader" "java.lang.ClassLoader"]
(register-class-statics! nm @{"getSystemClassLoader" (fn [] @{:jolt/type :jolt/classloader})}))
(register-tagged-methods! :jolt/classloader
@{"getResource" (fn [self path] nil)
"getResources" (fn [self path] nil)
"getResourceAsStream" (fn [self path] nil)})
# next.jdbc host shims (paired with the __jdbc-* builtins in core.janet and the
# instance? Connection case in evaluator.janet). The wrapped connection carries
# a clj :exec callback (run one SQL string) and a :close callback.
@ -563,7 +572,85 @@
(def out @[])
(each c (get self :cmds) (array/push out ((get self :exec) c)))
out)
"close" (fn [self] nil)}))
"close" (fn [self] nil)})
# java.io.File model (jolt-hjw). A :jolt/file carries its path; io/file and the
# File. ctor build it (see core.janet's __make-file). The method surface below
# is backed by os/ and file/. listFiles returns child :jolt/file values so
# file-seq (File-aware) yields :jolt/file leaves migratus can call methods on.
(defn- jfile-path [x]
(if (and (table? x) (= :jolt/file (get x :jolt/type))) (get x :path) (string x)))
(defn- make-jfile [path &opt child]
@{:jolt/type :jolt/file
:path (if child (string (jfile-path path) "/" (jfile-path child)) (jfile-path path))})
(defn- last-slash [p]
(var idx nil) (var i 0)
(while (< i (length p)) (when (= (p i) 47) (set idx i)) (++ i))
idx)
(defn- jfile-name [p]
(if-let [i (last-slash p)] (string/slice p (+ i 1)) p))
(defn- jfile-abs [p]
(if (string/has-prefix? "/" p) p (string (os/cwd) "/" p)))
(each nm ["File" "java.io.File"]
(register-class-ctor! nm make-jfile))
(register-tagged-methods! :jolt/file
@{"getPath" (fn [self] (get self :path))
"toString" (fn [self] (get self :path))
"getName" (fn [self] (jfile-name (get self :path)))
"getParent" (fn [self] (let [p (get self :path)]
(if-let [i (last-slash p)] (string/slice p 0 i) nil)))
"getAbsolutePath" (fn [self] (jfile-abs (get self :path)))
"getCanonicalPath" (fn [self] (jfile-abs (get self :path)))
"getAbsoluteFile" (fn [self] (make-jfile (jfile-abs (get self :path))))
"exists" (fn [self] (not (nil? (os/stat (get self :path)))))
"isFile" (fn [self] (= :file (os/stat (get self :path) :mode)))
"isDirectory" (fn [self] (= :directory (os/stat (get self :path) :mode)))
"canRead" (fn [self] (not (nil? (os/stat (get self :path)))))
# listFiles: child File values, or nil when not a directory (Clojure null)
"listFiles" (fn [self]
(let [p (get self :path)]
(when (= :directory (os/stat p :mode))
(map (fn [e] (make-jfile p e)) (os/dir p)))))
"list" (fn [self]
(let [p (get self :path)]
(when (= :directory (os/stat p :mode)) (os/dir p))))
"toPath" (fn [self] @{:jolt/type :jolt/nio-path :s (get self :path)})
"toURI" (fn [self] (string "file:" (jfile-abs (get self :path))))
"toURL" (fn [self] @{:jolt/type :jolt/url :url (string "file:" (jfile-abs (get self :path)))})
"delete" (fn [self] (let [r (protect (os/rm (get self :path)))] (truthy? (r 0))))
"mkdir" (fn [self] (truthy? ((protect (os/mkdir (get self :path))) 0)))
"mkdirs" (fn [self] (truthy? ((protect (os/mkdir (get self :path))) 0)))
"createNewFile" (fn [self]
(let [p (get self :path)]
(if (os/stat p) false
(do (def f (file/open p :w)) (file/close f) true))))
"equals" (fn [self o] (and (table? o) (= (get self :path) (get o :path))))
"hashCode" (fn [self] (hash (get self :path)))})
# java.nio.file degrade for migratus's script-excluded? glob check: just enough
# of Path / FileSystem / PathMatcher to match a filename against a glob, with a
# simple recursive * / ? matcher (no path-segment semantics — filenames only).
(defn- glob-matches? [glob s]
(defn m [gi si]
(cond
(= gi (length glob)) (= si (length s))
(= (glob gi) 42) # *
(or (m (+ gi 1) si)
(and (< si (length s)) (m gi (+ si 1))))
(and (< si (length s))
(or (= (glob gi) 63) (= (glob gi) (s si)))) # ? or literal
(m (+ gi 1) (+ si 1))
false))
(m 0 0))
(register-tagged-methods! :jolt/nio-path
@{"getFileSystem" (fn [self] @{:jolt/type :jolt/nio-fs})
"toString" (fn [self] (get self :s))})
(register-tagged-methods! :jolt/nio-fs
@{"getPath" (fn [self s & _] @{:jolt/type :jolt/nio-path :s s})
"getPathMatcher" (fn [self spec]
@{:jolt/type :jolt/nio-matcher
:glob (if (string/has-prefix? "glob:" spec)
(string/slice spec 5) spec)})})
(register-tagged-methods! :jolt/nio-matcher
@{"matches" (fn [self path] (glob-matches? (get self :glob) (get path :s)))}))
(install!)
(install-io!)