Close portable clojure.core gaps: re-groups, letfn, REPL + dynamic vars

Spec coverage dashboard had 6 missing-portable and 24 dynamic-var entries. The
portable ones are now implemented (missing-portable -> 0, dynamic-var -> 14):

- Stateful matcher: re-matcher now returns a real mutable Matcher; re-find over
  it steps through matches and re-groups returns the last match's groups (was an
  inert tagged map). Closes re-groups.
- letfn is interned as a clojure.core var so (resolve 'letfn) matches the JVM. It
  stays a special form (the value is never invoked, not marked a macro).
- *1 *2 *3 *e interned (nil outside a REPL).
- Portable dynamic vars whose default already matches jolt's behaviour:
  *read-eval* *print-dup* *print-namespace-maps* *flush-on-newline*
  *compile-files* *math-context* *command-line-args* *file*.

The remaining 14 dynamic-var entries are host-internal (compile-path,
compiler-options, fn-loader, reader-resolver, repl, source-path, ...) or deferred
pending printer/reader support (*print-length* *print-level*
*default-data-reader-fn*). Corpus rows added for each closed gap; coverage.md
regenerated.
This commit is contained in:
Yogthos 2026-06-26 17:48:21 -04:00
parent 8a877662dc
commit 5f72ec9bcb
5 changed files with 117 additions and 41 deletions

View file

@ -1,21 +1,21 @@
# Appendix A — Coverage Dashboard (generated) # Appendix A — Coverage Dashboard (generated)
Generated 2026-06-22 by `tools/spec_coverage.py` — do not edit by hand. Generated 2026-06-26 by `tools/spec_coverage.py` — do not edit by hand.
Surface: **694** clojure.core vars (ClojureDocs export; 648 with Surface: **694** clojure.core vars (ClojureDocs export; 648 with
community examples). jolt interns 574 of them. community examples). jolt interns 591 of them.
| Status | Count | Meaning | | Status | Count | Meaning |
|---|---|---| |---|---|---|
| implemented+tested | 568 | in jolt and exercised by spec/conformance | | implemented+tested | 587 | in jolt and exercised by spec/conformance |
| implemented-untested | 6 | in jolt, no direct test — spec entries will add them | | implemented-untested | 4 | in jolt, no direct test — spec entries will add them |
| resolvable-not-interned | 0 | works in code but invisible to ns introspection (conformance finding) | | resolvable-not-interned | 0 | works in code but invisible to ns introspection (conformance finding) |
| missing-portable | 6 | portable semantics, jolt lacks it — implementation gap | | missing-portable | 0 | portable semantics, jolt lacks it — implementation gap |
| special-form | 16 | specified in §3, not a library var | | special-form | 16 | specified in §3, not a library var |
| dynamic-var | 24 | classification needed: portable default vs host-dependent | | dynamic-var | 14 | classification needed: portable default vs host-dependent |
| agents-taps | 16 | out of scope pending concurrency design note | | agents-taps | 16 | out of scope pending concurrency design note |
| stm-refs | 11 | out of scope pending concurrency design note | | stm-refs | 11 | out of scope pending concurrency design note |
| jvm-specific | 47 | catalogued, not specified | | jvm-specific | 46 | catalogued, not specified |
Classifications are initial and mechanical — reclassifying is an ordinary Classifications are initial and mechanical — reclassifying is an ordinary
spec change. A var is *Verified* only when its §9 entry exists and carries no spec change. A var is *Verified* only when its §9 entry exists and carries no
@ -27,35 +27,35 @@ UNVERIFIED field; that column will be added as entries land.
|---|---|---| |---|---|---|
| `*` | implemented+tested | ✓ | | `*` | implemented+tested | ✓ |
| `*'` | implemented+tested | ✓ | | `*'` | implemented+tested | ✓ |
| `*1` | missing-portable | ✓ | | `*1` | implemented+tested | ✓ |
| `*2` | missing-portable | ✓ | | `*2` | implemented+tested | ✓ |
| `*3` | missing-portable | ✓ | | `*3` | implemented+tested | ✓ |
| `*agent*` | dynamic-var | ✓ | | `*agent*` | dynamic-var | ✓ |
| `*allow-unresolved-vars*` | dynamic-var | ✓ | | `*allow-unresolved-vars*` | dynamic-var | ✓ |
| `*assert*` | implemented+tested | ✓ | | `*assert*` | implemented+tested | ✓ |
| `*clojure-version*` | implemented+tested | ✓ | | `*clojure-version*` | implemented+tested | ✓ |
| `*command-line-args*` | dynamic-var | ✓ | | `*command-line-args*` | implemented-untested | ✓ |
| `*compile-files*` | dynamic-var | ✓ | | `*compile-files*` | implemented+tested | ✓ |
| `*compile-path*` | dynamic-var | ✓ | | `*compile-path*` | dynamic-var | ✓ |
| `*compiler-options*` | dynamic-var | ✓ | | `*compiler-options*` | dynamic-var | ✓ |
| `*data-readers*` | dynamic-var | ✓ | | `*data-readers*` | implemented+tested | ✓ |
| `*default-data-reader-fn*` | dynamic-var | ✓ | | `*default-data-reader-fn*` | dynamic-var | ✓ |
| `*e` | missing-portable | ✓ | | `*e` | implemented+tested | ✓ |
| `*err*` | implemented-untested | ✓ | | `*err*` | implemented+tested | ✓ |
| `*file*` | dynamic-var | ✓ | | `*file*` | implemented-untested | ✓ |
| `*flush-on-newline*` | dynamic-var | | | `*flush-on-newline*` | implemented+tested | |
| `*fn-loader*` | dynamic-var | | | `*fn-loader*` | dynamic-var | |
| `*in*` | implemented+tested | | | `*in*` | implemented+tested | |
| `*math-context*` | dynamic-var | | | `*math-context*` | implemented+tested | |
| `*ns*` | implemented+tested | ✓ | | `*ns*` | implemented+tested | ✓ |
| `*out*` | implemented-untested | ✓ | | `*out*` | implemented+tested | ✓ |
| `*print-dup*` | dynamic-var | ✓ | | `*print-dup*` | implemented+tested | ✓ |
| `*print-length*` | dynamic-var | ✓ | | `*print-length*` | dynamic-var | ✓ |
| `*print-level*` | dynamic-var | ✓ | | `*print-level*` | dynamic-var | ✓ |
| `*print-meta*` | dynamic-var | ✓ | | `*print-meta*` | implemented+tested | ✓ |
| `*print-namespace-maps*` | dynamic-var | ✓ | | `*print-namespace-maps*` | implemented-untested | ✓ |
| `*print-readably*` | implemented+tested | ✓ | | `*print-readably*` | implemented+tested | ✓ |
| `*read-eval*` | dynamic-var | ✓ | | `*read-eval*` | implemented+tested | ✓ |
| `*reader-resolver*` | dynamic-var | | | `*reader-resolver*` | dynamic-var | |
| `*repl*` | dynamic-var | | | `*repl*` | dynamic-var | |
| `*source-path*` | dynamic-var | ✓ | | `*source-path*` | dynamic-var | ✓ |
@ -63,7 +63,7 @@ UNVERIFIED field; that column will be added as entries land.
| `*unchecked-math*` | implemented+tested | ✓ | | `*unchecked-math*` | implemented+tested | ✓ |
| `*use-context-classloader*` | dynamic-var | ✓ | | `*use-context-classloader*` | dynamic-var | ✓ |
| `*verbose-defrecords*` | dynamic-var | | | `*verbose-defrecords*` | dynamic-var | |
| `*warn-on-reflection*` | implemented-untested | ✓ | | `*warn-on-reflection*` | implemented+tested | ✓ |
| `+` | implemented+tested | ✓ | | `+` | implemented+tested | ✓ |
| `+'` | implemented+tested | ✓ | | `+'` | implemented+tested | ✓ |
| `-` | implemented+tested | ✓ | | `-` | implemented+tested | ✓ |
@ -131,7 +131,7 @@ UNVERIFIED field; that column will be added as entries land.
| `assoc-in` | implemented+tested | ✓ | | `assoc-in` | implemented+tested | ✓ |
| `associative?` | implemented+tested | ✓ | | `associative?` | implemented+tested | ✓ |
| `atom` | implemented+tested | ✓ | | `atom` | implemented+tested | ✓ |
| `await` | implemented-untested | ✓ | | `await` | implemented+tested | ✓ |
| `await-for` | agents-taps | ✓ | | `await-for` | agents-taps | ✓ |
| `await1` | agents-taps | | | `await1` | agents-taps | |
| `bases` | jvm-specific | ✓ | | `bases` | jvm-specific | ✓ |
@ -218,7 +218,7 @@ UNVERIFIED field; that column will be added as entries land.
| `declare` | implemented+tested | ✓ | | `declare` | implemented+tested | ✓ |
| `dedupe` | implemented+tested | ✓ | | `dedupe` | implemented+tested | ✓ |
| `def` | special-form | ✓ | | `def` | special-form | ✓ |
| `default-data-readers` | jvm-specific | ✓ | | `default-data-readers` | implemented+tested | ✓ |
| `definline` | jvm-specific | | | `definline` | jvm-specific | |
| `definterface` | implemented+tested | ✓ | | `definterface` | implemented+tested | ✓ |
| `defmacro` | special-form | ✓ | | `defmacro` | special-form | ✓ |
@ -375,7 +375,7 @@ UNVERIFIED field; that column will be added as entries land.
| `lazy-cat` | implemented+tested | ✓ | | `lazy-cat` | implemented+tested | ✓ |
| `lazy-seq` | implemented+tested | ✓ | | `lazy-seq` | implemented+tested | ✓ |
| `let` | implemented+tested | ✓ | | `let` | implemented+tested | ✓ |
| `letfn` | missing-portable | ✓ | | `letfn` | implemented+tested | ✓ |
| `line-seq` | implemented+tested | ✓ | | `line-seq` | implemented+tested | ✓ |
| `list` | implemented+tested | ✓ | | `list` | implemented+tested | ✓ |
| `list*` | implemented+tested | ✓ | | `list*` | implemented+tested | ✓ |
@ -512,7 +512,7 @@ UNVERIFIED field; that column will be added as entries land.
| `rational?` | implemented+tested | ✓ | | `rational?` | implemented+tested | ✓ |
| `rationalize` | implemented+tested | ✓ | | `rationalize` | implemented+tested | ✓ |
| `re-find` | implemented+tested | ✓ | | `re-find` | implemented+tested | ✓ |
| `re-groups` | missing-portable | ✓ | | `re-groups` | implemented+tested | ✓ |
| `re-matcher` | implemented+tested | ✓ | | `re-matcher` | implemented+tested | ✓ |
| `re-matches` | implemented+tested | ✓ | | `re-matches` | implemented+tested | ✓ |
| `re-pattern` | implemented+tested | ✓ | | `re-pattern` | implemented+tested | ✓ |
@ -558,7 +558,7 @@ UNVERIFIED field; that column will be added as entries land.
| `reset-vals!` | implemented+tested | ✓ | | `reset-vals!` | implemented+tested | ✓ |
| `resolve` | implemented+tested | ✓ | | `resolve` | implemented+tested | ✓ |
| `rest` | implemented+tested | ✓ | | `rest` | implemented+tested | ✓ |
| `restart-agent` | implemented-untested | ✓ | | `restart-agent` | implemented+tested | ✓ |
| `resultset-seq` | jvm-specific | ✓ | | `resultset-seq` | jvm-specific | ✓ |
| `reverse` | implemented+tested | ✓ | | `reverse` | implemented+tested | ✓ |
| `reversible?` | implemented+tested | ✓ | | `reversible?` | implemented+tested | ✓ |

View file

@ -28,3 +28,35 @@
;; *print-meta* — when true, pr prints metadata with a ^ prefix; default false. ;; *print-meta* — when true, pr prints metadata with a ^ prefix; default false.
(def-var! "clojure.core" "*print-meta*" #f) (def-var! "clojure.core" "*print-meta*" #f)
;; Portable clojure.core dynamic vars whose DEFAULT already matches jolt's
;; behaviour, so exposing them is sound (resolve/binding work, reads return the
;; right value) — not a silent divergence. The ones that change observable output
;; if set non-default (*print-length* / *print-level* / *default-data-reader-fn*)
;; are deliberately NOT interned until the printer/reader honour them.
;;
;; *read-eval* — gates #=() read-eval. jolt's reader has no #=, so it reads true
;; (no eval-on-read happens regardless); a lib can (binding [*read-eval* false] …).
(def-var! "clojure.core" "*read-eval*" #t)
;; *print-dup* — gates print-dup (a multimethod that exists); default false.
(def-var! "clojure.core" "*print-dup*" #f)
;; *print-namespace-maps* — jolt never prints the #:ns{…} map shorthand, so the
;; var reads false (accurate); settable for code that toggles it.
(def-var! "clojure.core" "*print-namespace-maps*" #f)
;; *flush-on-newline* — jolt flushes line output; default true.
(def-var! "clojure.core" "*flush-on-newline*" #t)
;; *compile-files* — jolt has no separate compile phase that emits .class files.
(def-var! "clojure.core" "*compile-files*" #f)
;; *math-context* — BigDecimal rounding context; nil = unlimited, jolt's default.
(def-var! "clojure.core" "*math-context*" jolt-nil)
;; *command-line-args* — the args after the script/-main; nil outside a -m run.
(def-var! "clojure.core" "*command-line-args*" jolt-nil)
;; *file* — the source file being loaded; "NO_SOURCE_PATH" when none, like the JVM.
(def-var! "clojure.core" "*file*" "NO_SOURCE_PATH")
;; REPL result/exception history. Bound by the REPL after each evaluation; nil
;; outside a REPL, which is what reading them returns here.
(def-var! "clojure.core" "*1" jolt-nil)
(def-var! "clojure.core" "*2" jolt-nil)
(def-var! "clojure.core" "*3" jolt-nil)
(def-var! "clojure.core" "*e" jolt-nil)

View file

@ -16,20 +16,15 @@
(seq->list (jolt-seq names)))) (seq->list (jolt-seq names))))
jolt-nil) jolt-nil)
;; --- reader-conditional / re-matcher: tagged maps (reader-conditional? + the ;; --- reader-conditional: a tagged map (reader-conditional? is an overlay
;; matcher consumers are overlay tagged-value predicates that read :jolt/type). ;; tagged-value predicate that reads :jolt/type). re-matcher / re-find / re-groups
;; are the stateful matcher API in regex.ss.
(define nr-kw-type (keyword "jolt" "type")) (define nr-kw-type (keyword "jolt" "type"))
(define nr-kw-rc (keyword "jolt" "reader-conditional")) (define nr-kw-rc (keyword "jolt" "reader-conditional"))
(define nr-kw-form (keyword #f "form")) (define nr-kw-form (keyword #f "form"))
(define nr-kw-spl (keyword #f "splicing?")) (define nr-kw-spl (keyword #f "splicing?"))
(define nr-kw-mat (keyword "jolt" "matcher"))
(define nr-kw-re (keyword #f "re"))
(define nr-kw-s (keyword #f "s"))
(define nr-kw-pos (keyword #f "pos"))
(define (nr-reader-conditional form splicing?) (define (nr-reader-conditional form splicing?)
(jolt-hash-map nr-kw-type nr-kw-rc nr-kw-form form nr-kw-spl splicing?)) (jolt-hash-map nr-kw-type nr-kw-rc nr-kw-form form nr-kw-spl splicing?))
(define (nr-re-matcher re s)
(jolt-hash-map nr-kw-type nr-kw-mat nr-kw-re re nr-kw-s s nr-kw-pos 0.0))
;; --- macroexpand-1 / macroexpand: expand a (quoted) call form via the runtime ;; --- macroexpand-1 / macroexpand: expand a (quoted) call form via the runtime
;; macro table (host-contract hc-macro?/hc-expand-1; forward-referenced, resolved ;; macro table (host-contract hc-macro?/hc-expand-1; forward-referenced, resolved
@ -47,6 +42,13 @@
(def-var! "clojure.core" "__reader-features" nr-reader-features-get) (def-var! "clojure.core" "__reader-features" nr-reader-features-get)
(def-var! "clojure.core" "__reader-features-set!" nr-reader-features-set!) (def-var! "clojure.core" "__reader-features-set!" nr-reader-features-set!)
(def-var! "clojure.core" "reader-conditional" nr-reader-conditional) (def-var! "clojure.core" "reader-conditional" nr-reader-conditional)
(def-var! "clojure.core" "re-matcher" nr-re-matcher)
(def-var! "clojure.core" "macroexpand-1" nr-macroexpand-1) (def-var! "clojure.core" "macroexpand-1" nr-macroexpand-1)
;; letfn is a special form (the analyzer lowers it to letrec*, checked before any
;; macro), but on the JVM it is also a clojure.core macro that (resolve 'letfn)
;; finds — like let / loop / fn here. Intern a var so resolution matches; the value
;; is never invoked (the analyzer handles every (letfn …) form), and it is NOT
;; marked a macro, so macroexpand leaves a letfn form alone (it is special).
(def-var! "clojure.core" "letfn"
(lambda args (jolt-throw (jolt-ex-info "letfn is a special form" (jolt-hash-map)))))
(def-var! "clojure.core" "macroexpand" nr-macroexpand) (def-var! "clojure.core" "macroexpand" nr-macroexpand)

View file

@ -182,9 +182,41 @@
(let ((m (irregex-match (regex-t-irx (jolt-re-pattern re)) s))) (let ((m (irregex-match (regex-t-irx (jolt-re-pattern re)) s)))
(if m (irx-result m) jolt-nil))) (if m (irx-result m) jolt-nil)))
(define (jolt-re-find re s) ;; A stateful matcher (java.util.regex.Matcher): the compiled pattern, the target
(let ((m (irregex-search (regex-t-irx (jolt-re-pattern re)) s))) ;; string, the next search position, and the last successful irregex match. re-find
(if m (irx-result m) jolt-nil))) ;; over a matcher steps through non-overlapping matches; re-groups returns the
;; groups of the last one.
(define-record-type matcher-t
(fields irx str (mutable pos) (mutable last))
(nongenerative jolt-matcher-v1))
(define (jolt-re-matcher re s)
(make-matcher-t (regex-t-irx (jolt-re-pattern re)) s 0 #f))
(define (jolt-matcher? x) (matcher-t? x))
;; re-find: stateless over (re s), or stateful over a matcher (advance + remember).
(define jolt-re-find
(case-lambda
((re s)
(let ((m (irregex-search (regex-t-irx (jolt-re-pattern re)) s)))
(if m (irx-result m) jolt-nil)))
((m)
(let* ((str (matcher-t-str m))
(len (string-length str))
(start (matcher-t-pos m))
(mm (and (<= start len) (irregex-search (matcher-t-irx m) str start))))
(if mm
(let ((e (irregex-match-end-index mm 0)))
(matcher-t-last-set! m mm)
(matcher-t-pos-set! m (if (> e start) e (+ start 1)))
(irx-result mm))
(begin (matcher-t-last-set! m #f) jolt-nil))))))
;; re-groups: the groups of the matcher's last successful find. Throws when no
;; match has succeeded, like Clojure's IllegalStateException "No match found".
(define (jolt-re-groups m)
(let ((last (matcher-t-last m)))
(if last (irx-result last)
(jolt-throw (jolt-ex-info "No match found" (jolt-hash-map))))))
;; All non-overlapping matches, left to right. Advance past each match end (or by ;; All non-overlapping matches, left to right. Advance past each match end (or by
;; one on a zero-width match). nil when there are no matches (Clojure: seq-able as ;; one on a zero-width match). nil when there are no matches (Clojure: seq-able as
@ -203,4 +235,6 @@
(def-var! "clojure.core" "re-matches" jolt-re-matches) (def-var! "clojure.core" "re-matches" jolt-re-matches)
(def-var! "clojure.core" "re-find" jolt-re-find) (def-var! "clojure.core" "re-find" jolt-re-find)
(def-var! "clojure.core" "re-seq" jolt-re-seq) (def-var! "clojure.core" "re-seq" jolt-re-seq)
(def-var! "clojure.core" "re-matcher" jolt-re-matcher)
(def-var! "clojure.core" "re-groups" jolt-re-groups)
(def-var! "clojure.core" "regex?" jolt-regex?) (def-var! "clojure.core" "regex?" jolt-regex?)

View file

@ -1827,6 +1827,14 @@
{:suite "regex / char-class dash" :label "dash + escaped dot in class" :expected "\"a.b_c-d\"" :actual "(re-matches #\"[\\w-_\\.]+\" \"a.b_c-d\")"} {:suite "regex / char-class dash" :label "dash + escaped dot in class" :expected "\"a.b_c-d\"" :actual "(re-matches #\"[\\w-_\\.]+\" \"a.b_c-d\")"}
{:suite "regex / char-class dash" :label "trailing dash in class" :expected "[\"a-b\" \"c-d\"]" :actual "(vec (re-seq #\"[\\w-]+\" \"a-b c-d\"))"} {:suite "regex / char-class dash" :label "trailing dash in class" :expected "[\"a-b\" \"c-d\"]" :actual "(vec (re-seq #\"[\\w-]+\" \"a-b c-d\"))"}
{:suite "regex / nested quantifier" :label "(X+)* parses and matches" :expected "true" :actual "(boolean (re-matches #\"([^%]+)*(%(d))?\" \"abc\"))"} {:suite "regex / nested quantifier" :label "(X+)* parses and matches" :expected "true" :actual "(boolean (re-matches #\"([^%]+)*(%(d))?\" \"abc\"))"}
{:suite "regex / matcher" :label "re-find over a matcher" :expected "\"1\"" :actual "(re-find (re-matcher #\"\\d+\" \"a1b22\"))"}
{:suite "regex / matcher" :label "stateful step + re-groups" :expected "[[\"12-34\" \"12\" \"34\"] [\"12-34\" \"12\" \"34\"] [\"56-78\" \"56\" \"78\"] nil]" :actual "(let [m (re-matcher #\"(\\d+)-(\\d+)\" \"12-34 56-78\")] [(re-find m) (re-groups m) (re-find m) (re-find m)])"}
{:suite "regex / matcher" :label "re-groups before a match throws" :expected :throws :actual "(re-groups (re-matcher #\"\\d\" \"abc\"))"}
{:suite "dynamic vars / portable defaults" :label "default reads" :expected "[true false true false]" :actual "[*read-eval* *print-dup* *flush-on-newline* *compile-files*]"}
{:suite "dynamic vars / portable defaults" :label "binding *read-eval*" :expected "false" :actual "(binding [*read-eval* false] *read-eval*)"}
{:suite "special forms / letfn" :label "mutual recursion" :expected "true" :actual "(letfn [(e? [n] (if (zero? n) true (o? (dec n)))) (o? [n] (if (zero? n) false (e? (dec n))))] (e? 10))"}
{:suite "core vars / present and callable" :label "*out* *err* *warn-on-reflection* await restart-agent" :expected "[true true false true true]" :actual "[(boolean *out*) (boolean *err*) *warn-on-reflection* (ifn? await) (ifn? restart-agent)]"}
{:suite "dynamic vars / portable defaults" :label "*data-readers* map, *math-context* nil" :expected "[true true]" :actual "[(map? *data-readers*) (nil? *math-context*)]"}
{:suite "regex / re-pattern & string ops" :label "re-pattern build" :expected "\"hi\"" :actual "(re-find (re-pattern \"\\\\w+\") \"hi!\")"} {:suite "regex / re-pattern & string ops" :label "re-pattern build" :expected "\"hi\"" :actual "(re-find (re-pattern \"\\\\w+\") \"hi!\")"}
{:suite "regex / re-pattern & string ops" :label "re-pattern is regex?" :expected "true" :actual "(regex? (re-pattern \"a\"))"} {:suite "regex / re-pattern & string ops" :label "re-pattern is regex?" :expected "true" :actual "(regex? (re-pattern \"a\"))"}
{:suite "regex / re-pattern & string ops" :label "split on regex" :expected "[\"a\" \"b\" \"c\"]" :actual "(do (require '[clojure.string :as s]) (s/split \"a1b2c\" #\"\\d\"))"} {:suite "regex / re-pattern & string ops" :label "split on regex" :expected "[\"a\" \"b\" \"c\"]" :actual "(do (require '[clojure.string :as s]) (s/split \"a1b2c\" #\"\\d\"))"}