From e58be2fbd27309fd0beaa5d81fbd17104a9de9af Mon Sep 17 00:00:00 2001 From: Yogthos Date: Wed, 10 Jun 2026 12:19:23 -0400 Subject: [PATCH] core: #inst instant values + syntax-quote literal collapse (spec 2.3/2.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #inst (jolt-rnh): an instant is an immutable tagged struct {:jolt/type :jolt/inst :ms } — equality and map-key hashing by INSTANT, so different offsets denoting the same moment are =. The reader parses RFC3339 with Clojure's partial-timestamp defaults (#inst "2020" is 2020-01-01T00:00:00.000Z) and errors on malformed input; inst?/inst-ms in the overlay; pr-str prints the canonical #inst "yyyy-MM-ddThh:mm:ss.fff-00:00" and round-trips; str gives the bare RFC3339 string. Self-evaluating in the evaluator (like uuid/chars). Syntax-quote (jolt-l2a): a syntax-quoted self-evaluating literal (string, number, boolean, nil, keyword, char) collapses to the literal at READ time, matching Clojure's reader — so nested/adjacent backticks over literals are inert: (= "meow" ```"meow") is true (jank pass-adjacent). Symbols still qualify and collections still template. General nested syntax-quote over non-literals remains UNVERIFIED in spec S25. Tests: inst-spec (18 cases incl. partial defaults, offsets, round-trip), +10 literal-collapse reader rows, +5 conformance rows (321x3). Spec 02-reader S20/S25 updated to normative. Suite stable 4470/86. --- docs/spec/02-reader.md | 20 ++++--- docs/spec/coverage.md | 8 +-- jolt-core/clojure/core/20-coll.clj | 6 ++ src/jolt/core.janet | 4 ++ src/jolt/evaluator.janet | 6 +- src/jolt/reader.janet | 16 +++++- src/jolt/types.janet | 74 ++++++++++++++++++++++++- test/integration/conformance-test.janet | 7 +++ test/spec/inst-spec.janet | 30 ++++++++++ test/spec/reader-forms-spec.janet | 15 +++++ 10 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 test/spec/inst-spec.janet diff --git a/docs/spec/02-reader.md b/docs/spec/02-reader.md index 793d417..67e4500 100644 --- a/docs/spec/02-reader.md +++ b/docs/spec/02-reader.md @@ -1,7 +1,7 @@ # §2 The Reader (Lexical Syntax) **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 model: jank's per-construct corpus, 62 files under `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 `fail-unsupported-tag`). - Built-in tags every implementation MUST provide: `#uuid "…"` → a UUID - value (§9 `parse-uuid` semantics — round-trips through printing), `#inst - "…"` → an instant (representation host-classified; ⚠ jolt currently reads - `#inst` as its bare string — divergence, filed). + value (§9 `parse-uuid` semantics — round-trips through printing), and + `#inst "…"` → an instant value: RFC3339 with partial-timestamp defaults + (`#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 (symbolic values, stacked discard, conditionals); `uuid-spec` reader-literal @@ -176,10 +179,11 @@ resolution: value; `~'sym` is the idiom for an intentionally-unqualified symbol. - S24. Syntax-quote distributes through collection literals (vectors, maps, sets) — qualification and unquoting apply inside them. -- S25. Nested syntax-quotes expand recursively (quasiquote semantics); a - syntax-quoted self-evaluating literal is the literal: `(= "meow" - ```"meow")` is true. ⚠ jolt currently fails S25 (filed) — UNVERIFIED - pending the fix. +- S25. A syntax-quoted self-evaluating literal is the literal, collapsed at + read time — so nested/adjacent backticks over literals are inert: + `(= "meow" ```"meow")` is true. General nested syntax-quote over symbols + and collections expands recursively (quasiquote semantics) — that general + case remains UNVERIFIED pending dedicated conformance rows. **Conformance**: jolt `reader-forms-spec` "syntax-quote" (gensym, unquote, splice) + conformance "syntax-quote fully-qualifies"; jank diff --git a/docs/spec/coverage.md b/docs/spec/coverage.md index b7fa36f..ebf4b7c 100644 --- a/docs/spec/coverage.md +++ b/docs/spec/coverage.md @@ -7,8 +7,8 @@ community examples). jolt interns 550 of them. | Status | Count | Meaning | |---|---|---| -| implemented+tested | 397 | in jolt and exercised by spec/conformance | -| implemented-untested | 153 | in jolt, no direct test — spec entries will add them | +| implemented+tested | 399 | in jolt and exercised by spec/conformance | +| 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) | | missing-portable | 19 | portable semantics, jolt lacks it — implementation gap | | 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 | ✓ | | `infinite?` | implemented+tested | ✓ | | `init-proxy` | implemented-untested | ✓ | -| `inst-ms` | implemented-untested | ✓ | +| `inst-ms` | implemented+tested | ✓ | | `inst-ms*` | missing-portable | | -| `inst?` | implemented-untested | ✓ | +| `inst?` | implemented+tested | ✓ | | `instance?` | implemented+tested | ✓ | | `int` | implemented+tested | ✓ | | `int-array` | implemented-untested | ✓ | diff --git a/jolt-core/clojure/core/20-coll.clj b/jolt-core/clojure/core/20-coll.clj index 4b69d10..54dd85e 100644 --- a/jolt-core/clojure/core/20-coll.clj +++ b/jolt-core/clojure/core/20-coll.clj @@ -287,6 +287,12 @@ (defn tagged-literal? [x] (= (get x :jolt/type) :jolt/tagged-literal)) (defn record? [x] (some? (get x :jolt/deftype))) (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 ;; (collisions: last entry in seq order wins, matching the reference). diff --git a/src/jolt/core.janet b/src/jolt/core.janet index c66075c..dde1708 100644 --- a/src/jolt/core.janet +++ b/src/jolt/core.janet @@ -1678,6 +1678,9 @@ (and (struct? v) (= :jolt/uuid (v :jolt/type))) (do (buffer/push-string buf "#uuid \"") (buffer/push-string buf (v :str)) (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)) (core-sorted-map? v) (pr-render-pairs buf (sorted-map-entries v)) (core-sorted-set? v) (pr-render-seq buf (v :items) "#{" "}") @@ -1709,6 +1712,7 @@ (and (struct? v) (= :symbol (v :jolt/type))) (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) (and (table? v) (= :jolt/var (get v :jolt/type))) (var-display v) (number? v) (fmt-number v) (= true v) "true" diff --git a/src/jolt/evaluator.janet b/src/jolt/evaluator.janet index c9f98e0..6d743de 100644 --- a/src/jolt/evaluator.janet +++ b/src/jolt/evaluator.janet @@ -1652,9 +1652,9 @@ (resolve-sym ctx bindings form) (if (= :jolt/char (form :jolt/type)) form - # a UUID value flowing back through eval (macro expansion, eval of a read - # form) is a self-evaluating literal, like chars. - (if (= :jolt/uuid (form :jolt/type)) + # a UUID/inst value flowing back through eval (macro expansion, eval of a + # read form) is a self-evaluating literal, like chars. + (if (or (= :jolt/uuid (form :jolt/type)) (= :jolt/inst (form :jolt/type))) form (if (= :jolt/set (form :jolt/type)) # evaluate each element (set literals like #{(inc 1)} must compute) diff --git a/src/jolt/reader.janet b/src/jolt/reader.janet index 7e5de89..4a6abd5 100644 --- a/src/jolt/reader.janet +++ b/src/jolt/reader.janet @@ -492,9 +492,23 @@ [form new-pos] (read-form s end)] [{: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] (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 "Normalize a metadata reader form (Clojure semantics): a symbol or string is a diff --git a/src/jolt/types.janet b/src/jolt/types.janet index 67df2bb..d92afd8 100644 --- a/src/jolt/types.janet +++ b/src/jolt/types.janet @@ -371,6 +371,78 @@ (put namespaces ns-sym 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 # equality and map-key hashing are case-insensitive by value (struct equality), # matching Clojure (java.util.UUID equality / cljs UUID). @@ -406,7 +478,7 @@ :source-paths @["jolt-core" "src/jolt"] :type-registry @{} :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))) dr)} # create the user namespace via a partial context diff --git a/test/integration/conformance-test.janet b/test/integration/conformance-test.janet index be3b345..70389a4 100644 --- a/test/integration/conformance-test.janet +++ b/test/integration/conformance-test.janet @@ -193,6 +193,13 @@ ["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\"]))"] ["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)))))"] ### ---- HIGH: aliased namespace calls ---- diff --git a/test/spec/inst-spec.janet b/test/spec/inst-spec.janet new file mode 100644 index 0000000..8edddd3 --- /dev/null +++ b/test/spec/inst-spec.janet @@ -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 }: +# 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\")"]) diff --git a/test/spec/reader-forms-spec.janet b/test/spec/reader-forms-spec.janet index e46f768..08e4d59 100644 --- a/test/spec/reader-forms-spec.janet +++ b/test/spec/reader-forms-spec.janet @@ -63,3 +63,18 @@ ["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 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)])"])