Merge pull request #33 from jolt-lang/preflight-inst-sq

Pre-flight: #inst instant values + syntax-quote literal collapse
This commit is contained in:
Dmitri Sotnikov 2026-06-10 12:19:54 -04:00 committed by GitHub
commit 517f7ba762
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 169 additions and 17 deletions

View file

@ -1,7 +1,7 @@
# §2 The Reader (Lexical Syntax) # §2 The Reader (Lexical Syntax)
**Status**: token grammar drafted; reader-macro catalog complete with **Status**: token grammar drafted; reader-macro catalog complete with
normative entries; syntax-quote section carries two open divergences. normative entries; #inst and literal-collapse divergences resolved.
Conformance: jolt `reader-forms-spec` + `reader-syntax-spec` (granularity Conformance: jolt `reader-forms-spec` + `reader-syntax-spec` (granularity
model: jank's per-construct corpus, 62 files under model: jank's per-construct corpus, 62 files under
`test/jank/{reader-macro,syntax-quote}` — adapted rows cited per entry). `test/jank/{reader-macro,syntax-quote}` — adapted rows cited per entry).
@ -150,9 +150,12 @@ false; `(NaN? ##NaN)` is true.
read value. An unknown tag MUST be a read error (jank read value. An unknown tag MUST be a read error (jank
`fail-unsupported-tag`). `fail-unsupported-tag`).
- Built-in tags every implementation MUST provide: `#uuid "…"` → a UUID - Built-in tags every implementation MUST provide: `#uuid "…"` → a UUID
value (§9 `parse-uuid` semantics — round-trips through printing), `#inst value (§9 `parse-uuid` semantics — round-trips through printing), and
"…"` → an instant (representation host-classified; ⚠ jolt currently reads `#inst "…"` → an instant value: RFC3339 with partial-timestamp defaults
`#inst` as its bare string — divergence, filed). (`#inst "2020"``#inst "2020-01-01T00:00:00.000-00:00"`), equality by
instant (offset-normalized), `inst?`/`inst-ms` (epoch milliseconds), printed
canonically as `#inst "yyyy-MM-ddThh:mm:ss.fff-00:00"` and round-tripping. A
malformed timestamp MUST be an error.
**Conformance** (2.3): jolt `reader-forms-spec` "#() (% %N %&)" + new rows **Conformance** (2.3): jolt `reader-forms-spec` "#() (% %N %&)" + new rows
(symbolic values, stacked discard, conditionals); `uuid-spec` reader-literal (symbolic values, stacked discard, conditionals); `uuid-spec` reader-literal
@ -176,10 +179,11 @@ resolution:
value; `~'sym` is the idiom for an intentionally-unqualified symbol. value; `~'sym` is the idiom for an intentionally-unqualified symbol.
- S24. Syntax-quote distributes through collection literals (vectors, maps, - S24. Syntax-quote distributes through collection literals (vectors, maps,
sets) — qualification and unquoting apply inside them. sets) — qualification and unquoting apply inside them.
- S25. Nested syntax-quotes expand recursively (quasiquote semantics); a - S25. A syntax-quoted self-evaluating literal is the literal, collapsed at
syntax-quoted self-evaluating literal is the literal: `(= "meow" read time — so nested/adjacent backticks over literals are inert:
```"meow")` is true. ⚠ jolt currently fails S25 (filed) — UNVERIFIED `(= "meow" ```"meow")` is true. General nested syntax-quote over symbols
pending the fix. and collections expands recursively (quasiquote semantics) — that general
case remains UNVERIFIED pending dedicated conformance rows.
**Conformance**: jolt `reader-forms-spec` "syntax-quote" (gensym, unquote, **Conformance**: jolt `reader-forms-spec` "syntax-quote" (gensym, unquote,
splice) + conformance "syntax-quote fully-qualifies"; jank splice) + conformance "syntax-quote fully-qualifies"; jank

View file

@ -7,8 +7,8 @@ community examples). jolt interns 550 of them.
| Status | Count | Meaning | | Status | Count | Meaning |
|---|---|---| |---|---|---|
| implemented+tested | 397 | in jolt and exercised by spec/conformance | | implemented+tested | 399 | in jolt and exercised by spec/conformance |
| implemented-untested | 153 | in jolt, no direct test — spec entries will add them | | implemented-untested | 151 | in jolt, no direct test — spec entries will add them |
| resolvable-not-interned | 22 | works in code but invisible to ns introspection (conformance finding) | | resolvable-not-interned | 22 | works in code but invisible to ns introspection (conformance finding) |
| missing-portable | 19 | portable semantics, jolt lacks it — implementation gap | | missing-portable | 19 | portable semantics, jolt lacks it — implementation gap |
| special-form | 13 | specified in §3, not a library var | | special-form | 13 | specified in §3, not a library var |
@ -345,9 +345,9 @@ UNVERIFIED field; that column will be added as entries land.
| `indexed?` | implemented+tested | ✓ | | `indexed?` | implemented+tested | ✓ |
| `infinite?` | implemented+tested | ✓ | | `infinite?` | implemented+tested | ✓ |
| `init-proxy` | implemented-untested | ✓ | | `init-proxy` | implemented-untested | ✓ |
| `inst-ms` | implemented-untested | ✓ | | `inst-ms` | implemented+tested | ✓ |
| `inst-ms*` | missing-portable | | | `inst-ms*` | missing-portable | |
| `inst?` | implemented-untested | ✓ | | `inst?` | implemented+tested | ✓ |
| `instance?` | implemented+tested | ✓ | | `instance?` | implemented+tested | ✓ |
| `int` | implemented+tested | ✓ | | `int` | implemented+tested | ✓ |
| `int-array` | implemented-untested | ✓ | | `int-array` | implemented-untested | ✓ |

View file

@ -287,6 +287,12 @@
(defn tagged-literal? [x] (= (get x :jolt/type) :jolt/tagged-literal)) (defn tagged-literal? [x] (= (get x :jolt/type) :jolt/tagged-literal))
(defn record? [x] (some? (get x :jolt/deftype))) (defn record? [x] (some? (get x :jolt/deftype)))
(defn uuid? [x] (= (get x :jolt/type) :jolt/uuid)) (defn uuid? [x] (= (get x :jolt/type) :jolt/uuid))
(defn inst? [x] (= (get x :jolt/type) :jolt/inst))
;; inst-ms: epoch milliseconds of an instant; throws on a non-inst (Clojure
;; protocol behavior).
(defn inst-ms [x]
(if (inst? x) (get x :ms) (throw (str "inst-ms requires an inst, got: " x))))
;; Clojure 1.11 map transformers. PHM base so transformed keys canonicalize ;; Clojure 1.11 map transformers. PHM base so transformed keys canonicalize
;; (collisions: last entry in seq order wins, matching the reference). ;; (collisions: last entry in seq order wins, matching the reference).

View file

@ -1678,6 +1678,9 @@
(and (struct? v) (= :jolt/uuid (v :jolt/type))) (and (struct? v) (= :jolt/uuid (v :jolt/type)))
(do (buffer/push-string buf "#uuid \"") (buffer/push-string buf (v :str)) (do (buffer/push-string buf "#uuid \"") (buffer/push-string buf (v :str))
(buffer/push-string buf "\"")) (buffer/push-string buf "\""))
(and (struct? v) (= :jolt/inst (v :jolt/type)))
(do (buffer/push-string buf "#inst \"") (buffer/push-string buf (inst->rfc3339 v))
(buffer/push-string buf "\""))
(and (table? v) (= :jolt/var (get v :jolt/type))) (buffer/push-string buf (var-display v)) (and (table? v) (= :jolt/var (get v :jolt/type))) (buffer/push-string buf (var-display v))
(core-sorted-map? v) (pr-render-pairs buf (sorted-map-entries v)) (core-sorted-map? v) (pr-render-pairs buf (sorted-map-entries v))
(core-sorted-set? v) (pr-render-seq buf (v :items) "#{" "}") (core-sorted-set? v) (pr-render-seq buf (v :items) "#{" "}")
@ -1709,6 +1712,7 @@
(and (struct? v) (= :symbol (v :jolt/type))) (and (struct? v) (= :symbol (v :jolt/type)))
(if (v :ns) (string (v :ns) "/" (v :name)) (v :name)) (if (v :ns) (string (v :ns) "/" (v :name)) (v :name))
(and (struct? v) (= :jolt/uuid (v :jolt/type))) (v :str) (and (struct? v) (= :jolt/uuid (v :jolt/type))) (v :str)
(and (struct? v) (= :jolt/inst (v :jolt/type))) (inst->rfc3339 v)
(and (table? v) (= :jolt/var (get v :jolt/type))) (var-display v) (and (table? v) (= :jolt/var (get v :jolt/type))) (var-display v)
(number? v) (fmt-number v) (number? v) (fmt-number v)
(= true v) "true" (= true v) "true"

View file

@ -1652,9 +1652,9 @@
(resolve-sym ctx bindings form) (resolve-sym ctx bindings form)
(if (= :jolt/char (form :jolt/type)) (if (= :jolt/char (form :jolt/type))
form form
# a UUID value flowing back through eval (macro expansion, eval of a read # a UUID/inst value flowing back through eval (macro expansion, eval of a
# form) is a self-evaluating literal, like chars. # read form) is a self-evaluating literal, like chars.
(if (= :jolt/uuid (form :jolt/type)) (if (or (= :jolt/uuid (form :jolt/type)) (= :jolt/inst (form :jolt/type)))
form form
(if (= :jolt/set (form :jolt/type)) (if (= :jolt/set (form :jolt/type))
# evaluate each element (set literals like #{(inc 1)} must compute) # evaluate each element (set literals like #{(inc 1)} must compute)

View file

@ -492,9 +492,23 @@
[form new-pos] (read-form s end)] [form new-pos] (read-form s end)]
[{:jolt/type :jolt/tagged :tag (keyword tag) :form form} new-pos])))) [{:jolt/type :jolt/tagged :tag (keyword tag) :form form} new-pos]))))
(defn- self-evaluating-literal?
"True for forms syntax-quote passes through unchanged: strings, numbers,
booleans, nil, keywords, and character literals. NOT symbols (they qualify)
or collections (they template)."
[form]
(or (nil? form) (= true form) (= false form) (number? form)
(string? form) (buffer? form) (keyword? form)
(and (struct? form) (= :jolt/char (form :jolt/type)))))
(defn read-quote [s pos new-pos token-sym] (defn read-quote [s pos new-pos token-sym]
(let [[form final-pos] (read-form s new-pos)] (let [[form final-pos] (read-form s new-pos)]
[(array token-sym form) final-pos])) # Spec 02-reader S25: syntax-quote of a self-evaluating literal is the
# literal, collapsed at READ time (matching Clojure's reader) — so nested
# backticks over literals are inert: ```"meow" reads as "meow".
(if (and (= "syntax-quote" (token-sym :name)) (self-evaluating-literal? form))
[form final-pos]
[(array token-sym form) final-pos])))
(defn- meta-form->map (defn- meta-form->map
"Normalize a metadata reader form (Clojure semantics): a symbol or string is a "Normalize a metadata reader form (Clojure semantics): a symbol or string is a

View file

@ -371,6 +371,78 @@
(put namespaces ns-sym ns) (put namespaces ns-sym ns)
ns)))) ns))))
# Instant value: an immutable tagged struct keyed by epoch milliseconds, so
# equality and map-key hashing are by INSTANT (offset-normalized): two #inst
# literals with different offsets denoting the same moment are =.
(defn make-inst [ms]
{:jolt/type :jolt/inst :ms ms})
(defn parse-inst
"Parse an RFC3339 timestamp with Clojure's partial defaults
(yyyy[-MM[-dd[Thh[:mm[:ss[.fff]]]]]][Z|+hh:mm|-hh:mm]) to an inst value.
Errors on a malformed timestamp."
[ts]
(def pat (peg/compile
~(sequence
(capture (repeat 4 :d)) # year
(opt (sequence "-" (capture (repeat 2 :d)))) # month
(opt (sequence "-" (capture (repeat 2 :d)))) # day
(opt (sequence "T" (capture (repeat 2 :d)) # hour
(opt (sequence ":" (capture (repeat 2 :d)) # min
(opt (sequence ":" (capture (repeat 2 :d)) # sec
(opt (sequence "." (capture (some :d)))))))))) # frac
(opt (choice (capture "Z")
(sequence (capture (set "+-")) (capture (repeat 2 :d))
":" (capture (repeat 2 :d)))))
-1)))
(def m (peg/match pat ts))
(when (nil? m) (error (string "Unrecognized #inst timestamp: " ts)))
# captures arrive positionally; classify by shape: digits runs + offset parts.
(var year nil) (var month 1) (var day 1)
(var hh 0) (var mm 0) (var ss 0) (var frac "0")
(var off-sign nil) (var off-h 0) (var off-m 0)
(var i 0)
(def fields @[:year :month :day :hh :mm :ss])
(var fi 0)
(while (< i (length m))
(def part (in m i))
(cond
(= part "Z") nil
(or (= part "+") (= part "-"))
(do (set off-sign part)
(set off-h (scan-number (in m (+ i 1))))
(set off-m (scan-number (in m (+ i 2))))
(+= i 2))
# fractional seconds arrive right after :ss was filled
(and (>= fi 6))
(set frac part)
(do
(def v (scan-number part))
(case (in fields fi)
:year (set year v) :month (set month v) :day (set day v)
:hh (set hh v) :mm (set mm v) :ss (set ss v))
(++ fi)))
(++ i))
(when (nil? year) (error (string "Unrecognized #inst timestamp: " ts)))
(def base-s (os/mktime {:year year :month (- month 1) :month-day (- day 1)
:hours hh :minutes mm :seconds ss}))
# fractional part -> milliseconds (truncate beyond 3 digits)
(def frac3 (string/slice (string frac "000") 0 3))
(def ms-frac (scan-number frac3))
(def off-s (* (if (= off-sign "-") -1 1) (+ (* off-h 3600) (* off-m 60))))
(make-inst (- (+ (* base-s 1000) ms-frac) (* off-s 1000))))
(defn inst->rfc3339
"Canonical print form: yyyy-MM-ddThh:mm:ss.fff-00:00 (UTC, like Clojure)."
[inst]
(def ms (inst :ms))
(def s (math/floor (/ ms 1000)))
(def frac (- ms (* s 1000)))
(def d (os/date s))
(string/format "%04d-%02d-%02dT%02d:%02d:%02d.%03d-00:00"
(d :year) (+ 1 (d :month)) (+ 1 (d :month-day))
(d :hours) (d :minutes) (d :seconds) frac))
# UUID value: an immutable tagged struct. Lowercased at construction so # UUID value: an immutable tagged struct. Lowercased at construction so
# equality and map-key hashing are case-insensitive by value (struct equality), # equality and map-key hashing are case-insensitive by value (struct equality),
# matching Clojure (java.util.UUID equality / cljs UUID). # matching Clojure (java.util.UUID equality / cljs UUID).
@ -406,7 +478,7 @@
:source-paths @["jolt-core" "src/jolt"] :source-paths @["jolt-core" "src/jolt"]
:type-registry @{} :type-registry @{}
:data-readers (let [dr @{}] :data-readers (let [dr @{}]
(put dr (keyword "#inst") (fn [s] s)) (put dr (keyword "#inst") (fn [s] (parse-inst s)))
(put dr (keyword "#uuid") (fn [s] (make-uuid s))) (put dr (keyword "#uuid") (fn [s] (make-uuid s)))
dr)} dr)}
# create the user namespace via a partial context # create the user namespace via a partial context

View file

@ -193,6 +193,13 @@
["macroexpand" "true" "(= (quote if) (first (macroexpand (quote (when-not false 1)))))"] ["macroexpand" "true" "(= (quote if) (first (macroexpand (quote (when-not false 1)))))"]
["require bare symbol" "\"a,b\"" "(do (require (quote clojure.string)) (clojure.string/join \",\" [\"a\" \"b\"]))"] ["require bare symbol" "\"a,b\"" "(do (require (quote clojure.string)) (clojure.string/join \",\" [\"a\" \"b\"]))"]
["ns-publics lookup" "true" "(do (def cnp 7) (some? (get (ns-publics (quote user)) (quote cnp))))"] ["ns-publics lookup" "true" "(do (def cnp 7) (some? (get (ns-publics (quote user)) (quote cnp))))"]
### ---- #inst + syntax-quote literal collapse (spec 2.4/2.3) ----
["inst? + inst-ms" "0" "(inst-ms #inst \"1970-01-01T00:00:00Z\")"]
["inst partial = full" "true" "(= #inst \"2020\" #inst \"2020-01-01T00:00:00Z\")"]
["inst offset normalized" "true" "(= #inst \"2020-01-01T01:00:00+01:00\" #inst \"2020-01-01T00:00:00Z\")"]
["sq literal collapse" "true" "(= \"meow\" ```\"meow\")"]
["sq number collapse" "42" "``42"]
["macroexpand-1 when" "2" "(count (rest (macroexpand-1 (quote (when true 1)))))"] ["macroexpand-1 when" "2" "(count (rest (macroexpand-1 (quote (when true 1)))))"]
### ---- HIGH: aliased namespace calls ---- ### ---- HIGH: aliased namespace calls ----

30
test/spec/inst-spec.janet Normal file
View file

@ -0,0 +1,30 @@
# Specification: #inst literals — instants as values (spec 02-reader S20).
# An instant is an immutable tagged struct {:jolt/type :jolt/inst :ms <epoch-ms>}:
# value equality by instant (offset-normalized), usable as map keys.
# The reader accepts RFC3339 with Clojure's partial-timestamp defaults
# (#inst "2020" == #inst "2020-01-01T00:00:00.000-00:00").
(use ../support/harness)
(defspec "inst / reading & identity"
["reads to inst" "true" "(inst? #inst \"2020-01-01T00:00:00Z\")"]
["inst? false on string" "false" "(inst? \"2020-01-01\")"]
["epoch zero" "0" "(inst-ms #inst \"1970-01-01T00:00:00Z\")"]
["one second" "1000" "(inst-ms #inst \"1970-01-01T00:00:01Z\")"]
["millis" "123" "(inst-ms #inst \"1970-01-01T00:00:00.123Z\")"]
["a real date" "1577836800000" "(inst-ms #inst \"2020-01-01T00:00:00Z\")"]
["inst-ms throws on non-inst" :throws "(inst-ms 42)"])
(defspec "inst / partial timestamps & offsets"
["year only" "true" "(= #inst \"2020\" #inst \"2020-01-01T00:00:00.000Z\")"]
["year-month" "true" "(= #inst \"2020-03\" #inst \"2020-03-01T00:00:00Z\")"]
["date only" "true" "(= #inst \"2020-03-15\" #inst \"2020-03-15T00:00:00Z\")"]
["positive offset" "true" "(= #inst \"2020-01-01T01:00:00+01:00\" #inst \"2020-01-01T00:00:00Z\")"]
["negative offset" "true" "(= #inst \"2019-12-31T23:00:00-01:00\" #inst \"2020-01-01T00:00:00Z\")"]
["-00:00 offset" "true" "(= #inst \"2020-01-01T00:00:00-00:00\" #inst \"2020-01-01T00:00:00Z\")"]
["bad timestamp throws" :throws "#inst \"garbage\""])
(defspec "inst / value semantics & printing"
["equal by instant" "true" "(= #inst \"2020-01-01T00:00:00Z\" #inst \"2020-01-01T00:00:00.000Z\")"]
["unequal instants" "false" "(= #inst \"2020-01-01T00:00:00Z\" #inst \"2020-01-01T00:00:01Z\")"]
["works as map key" ":v" "(get {#inst \"2020-01-01T00:00:00Z\" :v} #inst \"2020-01-01T00:00:00.000Z\")"]
["pr-str round-trips" "\"#inst \\\"2020-01-01T00:00:00.000-00:00\\\"\"" "(pr-str #inst \"2020-01-01T00:00:00Z\")"])

View file

@ -63,3 +63,18 @@
["var-quote qualified" "true" "(= (var clojure.core/str) #'clojure.core/str)"] ["var-quote qualified" "true" "(= (var clojure.core/str) #'clojure.core/str)"]
["gensym stable in template" "true" "(let [syms `[meow# meow#]] (= (first syms) (second syms)))"] ["gensym stable in template" "true" "(let [syms `[meow# meow#]] (= (first syms) (second syms)))"]
["gensym fresh across templates" "false" "(= `meow# `meow#)"]) ["gensym fresh across templates" "false" "(= `meow# `meow#)"])
# Spec 02-reader S25: syntax-quote of a self-evaluating literal is the literal
# (read-time collapse), so adjacent/nested backticks over literals are inert.
(defspec "reader / syntax-quote literal collapse (spec 2.4 S25)"
["string once" "true" "(= \"meow\" `\"meow\")"]
["string nested" "true" "(= \"meow\" ``\"meow\")"]
["string triple" "true" "(= \"meow\" ```\"meow\")"]
["number nested" "true" "(= 42 ``42)"]
["keyword nested" "true" "(= :k ``:k)"]
["nil nested" "true" "(nil? ``nil)"]
["char nested" "true" "(= \\a ``\\a)"]
["bool nested" "true" "(= true ``true)"]
# collapse must NOT apply to symbols (they qualify) or collections (templates)
["symbol still qualifies" "true" "(= (quote clojure.core/map) `map)"]
["vector still templates" "true" "(= [1 2] `[1 ~(inc 1)])"])