core: java shims for yogthos/config + three conformance fixes

yogthos/config now loads and runs end to end: config.edn/.lein-env
deep merge, env vars keywordized and type-converted, PushbackReader
over io/reader, reload-env via alter-var-root. New shims:
java.io.PushbackReader (read/unread), Long/parseLong, BigInteger.,
Boolean/parseBoolean, System/getProperties; clojure.edn/read now
drains an actual reader (it used to read one LINE from a raw janet
file handle, so multi-line config files and shim readers both broke).

Three real bugs shaken out, each with regression specs:

- An empty rest arg bound () instead of nil. ((fn [& r] (if r 1 2)))
  returned 1; the truthy () sent config's (or (.exists f) required)
  down the wrong branch. Fixed in the interpreter and the compiled-fn
  emission. Internal apply boundaries (protocol dispatch, core-apply)
  now accept a nil seq like Clojure's (apply f x nil).

- seq/map over a raw janet table (System/getenv, os/environ) yielded
  nothing, so config's read-system-env came back empty. Raw host
  tables now seq as kv entries like any map, in core-seq,
  realize-for-iteration, and coll->cells. The old spec row hid this
  behind a vacuous (every? pred empty) — replaced with one that
  asserts non-emptiness.

- edn/read single-line limitation, as above.

test/integration/config-lib-test.janet runs the real library from
~/src/config (skips when absent).
This commit is contained in:
Yogthos 2026-06-11 00:11:04 -04:00
parent 1673a0f024
commit d161e16df6
8 changed files with 215 additions and 12 deletions

View file

@ -137,7 +137,9 @@
(def nfixed (length (vview (ar :params))))
(def call @[(emit-arity-fn ctx ar)])
(for i 0 nfixed (array/push call ['in jargs i]))
(when (ar :rest) (array/push call ['tuple/slice jargs nfixed]))
# empty rest binds to NIL, not () — (f) with [& r] gives r = nil in Clojure
(when (ar :rest)
(array/push call ['if ['> ['length jargs] nfixed] ['tuple/slice jargs nfixed]]))
(tuple/slice call))
(defn- emit-loop [ctx node]

View file

@ -50,8 +50,20 @@
([s] (read-edn {} s))
([opts s] (read-edn opts s)))
(defn read
"Reads the next line from reader and parses one EDN object from it."
(defn- drain-reader
"All remaining content of a reader as a string. Shim readers (StringReader,
PushbackReader, io/reader results) expose char-wise .read; a raw janet file
handle is read whole."
[reader]
(let [line ((get (dyn :current-env) (symbol "file/read")) reader :line)]
(when line (read-string line))))
(if (= :core/file (janet/type reader))
(janet.file/read reader :all)
(loop [acc (transient []) c (.read reader)]
(if (== -1 c)
(apply str (map char (persistent! acc)))
(recur (conj! acc c) (.read reader))))))
(defn read
"Reads one EDN object from reader (a PushbackReader or any jolt reader).
Returns the :eof option value (default nil) at end of input."
([reader] (read {} reader))
([opts reader] (read-edn opts (drain-reader reader))))

View file

@ -132,6 +132,9 @@
(buffer? c) (let [a @[]] (each x c (array/push a x)) a)
# struct map literal (no :jolt/type marker — not a symbol/char) -> entries
(and (struct? c) (nil? (get c :jolt/type))) (map (fn [k] (tuple k (get c k))) (keys c))
# raw host table (System/getenv, os/environ) — also a map: entries
(and (table? c) (nil? (get c :jolt/type)) (nil? (get c :jolt/deftype)))
(map (fn [k] (tuple k (get c k))) (keys c))
(lazy-seq? c)
(do
(var items @[])
@ -619,7 +622,8 @@
(jolt-call f)
(let [fixed (array/slice args 0 (- n 1))
t (in args (- n 1))
tail (cond (set? t) (phs-seq t) (phm? t) (tuple ;(phm-entries t))
tail (cond (nil? t) [] # (apply f x nil) == (f x), as in Clojure
(set? t) (phs-seq t) (phm? t) (tuple ;(phm-entries t))
(realize-for-iteration t))]
(jolt-call f ;fixed ;tail)))))
@ -758,6 +762,10 @@
(struct? coll) (if (= 0 (length coll)) nil (tuple ;(map (fn [k] (tuple k (get coll k))) (keys coll))))
(array? coll) (tuple ;coll)
(and (table? coll) (get coll :jolt/deftype)) coll
# raw host table (System/getenv result) seqs like a map: kv entries
(and (table? coll) (nil? (get coll :jolt/type)))
(if (= 0 (length coll)) nil
(tuple ;(map (fn [k] (tuple k (get coll k))) (keys coll))))
# scalars/functions aren't seqable
(error (string "seq not supported on " (type coll)))))
@ -978,7 +986,10 @@
# Other concrete seqables (set/map/sorted coll/string/buffer): coerce
# to a tuple seq via core-seq, then recurse. (lazy/indexed above.)
(if (or (set? c) (phm? c) (buffer? c) (string? c) (core-sorted? c)
(and (struct? c) (nil? (get c :jolt/type))))
(and (struct? c) (nil? (get c :jolt/type)))
# raw host table (System/getenv) — a map: kv entries
(and (table? c) (nil? (get c :jolt/type))
(nil? (get c :jolt/deftype))))
(coll->cells (core-seq c))
nil)))))))))

View file

@ -453,13 +453,27 @@
"user.home" (os/getenv "HOME")
"java.io.tmpdir" (or (os/getenv "TMPDIR") "/tmp")
dflt))
"getenv" (fn [&opt k] (if k (os/getenv k) (os/environ)))})
"getenv" (fn [&opt k] (if k (os/getenv k) (os/environ)))
# 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})
"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 "\""))))})
# Pluggable host-class shims (java.time etc. register here at module load):
# class-statics: "ClassName" -> {"member" value-or-fn} (Foo/bar resolution)
@ -667,6 +681,12 @@
(do (array/push fixed a) (+= i 1)))))
{:fixed (tuple/slice (tuple ;fixed)) :rest rest-pat})
(defn- rest-args-val
"What a rest param binds to: nil when no args remain (Clojure semantics —
(fn [& r]) called with nothing gives r = nil, never an empty seq)."
[args i]
(when (> (length args) i) (tuple/slice args i)))
(defn- plain-sym? [p] (and (struct? p) (= :symbol (p :jolt/type))))
(defn- require-symbol-params
@ -850,6 +870,8 @@
# longer a special-form handler for them. proto/method/type names arrive as
# STRINGS (the defprotocol/extend-type macros pass (name sym), not the symbol).
(defn protocol-dispatch-impl [ctx proto-name method-name obj rest-args]
# an empty jolt rest arg is NIL (Clojure semantics); janet apply needs a tuple
(default rest-args [])
(def type-tag (if (and (table? obj) (get obj :jolt/deftype))
(get obj :jolt/deftype)
(if (get obj :jolt/protocol-methods) (get obj :jolt/deftype))))
@ -1545,7 +1567,7 @@
(destructure-bind ctx new-bindings pat (macro-args i))
(++ i))
(when rest-pat
(destructure-bind ctx new-bindings rest-pat (tuple/slice macro-args i)))
(destructure-bind ctx new-bindings rest-pat (rest-args-val macro-args i)))
# Use defining namespace for symbol resolution
(def saved-ns (ctx-current-ns ctx))
(ctx-set-current-ns ctx defining-ns)
@ -1646,7 +1668,7 @@
(destructure-bind ctx fn-bindings pat (fn-args i))
(++ i))
(when rest-pat
(destructure-bind ctx fn-bindings rest-pat (tuple/slice fn-args i)))
(destructure-bind ctx fn-bindings rest-pat (rest-args-val fn-args i)))
(run-clause fn-bindings))]
(if rest-pat
(do
@ -1705,7 +1727,7 @@
(destructure-bind ctx fn-bindings pat (fn-args i))
(++ i))
(when rest-pat
(destructure-bind ctx fn-bindings rest-pat (tuple/slice fn-args i)))
(destructure-bind ctx fn-bindings rest-pat (rest-args-val fn-args i)))
(run-body fn-bindings)))
# recur re-enters here: for a variadic fn it takes n-fixed + 1
# args, the LAST bound DIRECTLY as the rest seq (Clojure) — going

View file

@ -177,7 +177,34 @@
(and (struct? x) (= :jolt/char (get x :jolt/type))) (string/from-bytes (x :ch))
(string x)))
# Read one unit from any reader-ish value: our shims dispatch through their
# tagged "read"; a janet core/file reads one byte. -1 at EOF.
(defn- read-unit [r]
(cond
(and (or (table? r) (struct? r)) (get r :jolt/type))
(((get tagged-methods (r :jolt/type)) "read") r)
(= :core/file (type r))
(let [b (file/read r 1)] (if (or (nil? b) (= 0 (length b))) -1 (b 0)))
(error (string "not a reader: " (type r)))))
(defn- pushback-reader [rdr]
# java.io.PushbackReader: read delegates to the wrapped reader unless
# something was unread; unread takes a char (or char code) and pushes it back
(def self @{:jolt/type :jolt/pushback-reader :rdr rdr :pushed @[]
:close (fn [] nil)})
self)
(defn install-io! []
(register-tagged-methods! :jolt/pushback-reader
@{"read" (fn [self]
(if (> (length (self :pushed)) 0)
(array/pop (self :pushed))
(read-unit (self :rdr))))
"unread" (fn [self ch]
(array/push (self :pushed)
(if (number? ch) ch (get ch :ch)))
nil)
"close" (fn [self] nil)})
(register-tagged-methods! :jolt/jio-string-reader
@{"read" (fn [self]
(if (>= (self :pos) (length (self :s)))
@ -202,6 +229,9 @@
"charAt" (fn [self i] {:jolt/type :jolt/char :ch ((self :buf) i)})})
(each nm ["File" "java.io.File"]
(register-class-statics! nm @{"separator" "/" "separatorChar" {:jolt/type :jolt/char :ch 47}}))
(register-class-statics! "Boolean"
@{"parseBoolean" (fn [s] (= "true" (string/ascii-lower (string s))))
"TRUE" true "FALSE" false})
(register-class-statics! "Class"
@{"forName" (fn [nm] @{:jolt/type :jolt/class :name nm})})
(each nm ["StringReader" "java.io.StringReader"]
@ -230,6 +260,13 @@
(string/find "/" (string/slice s 0 colon)))
(error (string "MalformedURLException: no protocol: " s))
@{:jolt/type :jolt/url :spec s}))))
(each nm ["PushbackReader" "java.io.PushbackReader"]
(register-class-ctor! nm (fn [rdr &opt size] (pushback-reader rdr))))
(each nm ["BigInteger" "java.math.BigInteger"]
(register-class-ctor! nm
(fn [v]
(or (scan-number (string/trim (string v)))
(error (string "NumberFormatException: For input string: \"" v "\""))))))
(each nm ["Locale" "java.util.Locale"]
(register-class-ctor! nm (fn [id &opt _country] @{:jolt/type :jolt/locale :id (string id)}))))