From 75c1288c0d0ff172b767463050e431874e5dc9d7 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Wed, 9 Sep 2026 12:07:23 -0400 Subject: [PATCH 1/2] Print the ISO formatters' fraction the way java.time does ISO_ZONED_DATE_TIME rendered a fixed .SSS, so a zero fraction printed as .000 where the JVM omits it, and nanoseconds beyond the millisecond were cut; ISO_OFFSET_DATE_TIME and the other ISO constants printed no fraction at all. java.time's ISO_* formatters print the second's fraction only when it is non-zero, with as many digits as carry a value, up to nine. The ISO constants now carry :iso and the seconds letter reads it; a pattern's fixed-width S is unchanged. The strict parser already took an optional fraction after the seconds, so what these print parses back. Tests from the reference: omitted zero, .123, .123456789, .00012, .005, and the round trips (jolt-lang/jolt bead jolt-vjwh.17). --- src/jolt/time/fmt.clj | 39 +++++++++++++++++++++++++------------ test/jolt/time/fmt_test.clj | 19 ++++++++++++++++++ 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/jolt/time/fmt.clj b/src/jolt/time/fmt.clj index 4a2fd8a..8cd8762 100644 --- a/src/jolt/time/fmt.clj +++ b/src/jolt/time/fmt.clj @@ -74,7 +74,18 @@ (= \' (nth pattern j)) [(inc j) acc] :else (recur (inc j) (str acc (nth pattern j))))))) -(defn format-pattern [pattern v locale] +;; The ISO_* formatters print the second's fraction the way java.time's +;; ISO_LOCAL_TIME does -- omitted when zero, else as many digits as carry a +;; value, up to nine -- and a pattern's fixed-width S never does; so the rule +;; is a flag on the formatter (`iso-frac?`) that the seconds letter reads. +(defn- iso-fraction [nano] + (if (zero? nano) + "" + (let [s (u/pad-left (str nano) 9)] + (str "." (subs s 0 (inc (loop [i 8] (if (= \0 (nth s i)) (recur (dec i)) i)))))))) +(defn format-pattern + ([pattern v locale] (format-pattern pattern v locale false)) + ([pattern v locale iso-frac?] (let [p (parts v)] (if-not p (str v) (let [[y mo d hh mi se nano dow off zid] p n (count pattern)] @@ -93,17 +104,18 @@ (= c \H) (recur (+ i k) (str out (if (= k 1) (str hh) (u/pad2 hh)))) (= c \h) (recur (+ i k) (str out (let [h12 (let [h (mod hh 12)] (if (zero? h) 12 h))] (if (= k 1) (str h12) (u/pad2 h12))))) (= c \m) (recur (+ i k) (str out (if (= k 1) (str mi) (u/pad2 mi)))) - (= c \s) (recur (+ i k) (str out (if (= k 1) (str se) (u/pad2 se)))) + (= c \s) (recur (+ i k) (str out (if (= k 1) (str se) (u/pad2 se)) (if iso-frac? (iso-fraction nano) ""))) (= c \S) (recur (+ i k) (str out (u/pad-left (str (quot nano (u/pow10 (max 0 (- 9 k))))) k))) (= c \a) (recur (+ i k) (str out (if (< hh 12) "AM" "PM"))) (= c \X) (recur (+ i k) (str out (off-iso off (>= k 3) true))) (= c \x) (recur (+ i k) (str out (off-iso off (>= k 3) false))) (= c \Z) (recur (+ i k) (str out (off-iso off (>= k 3) false))) (or (= c \V) (= c \z)) (recur (+ i k) (str out (or zid (off-iso off true true)))) - :else (recur (inc i) (str out c))))))))))) + :else (recur (inc i) (str out c)))))))))))) ;; --- DateTimeFormatter ------------------------------------------------------- (defn formatter [pattern locale] (impl/value :jolt.time/dt-formatter {:pattern pattern :locale (or locale "en")})) +(defn- iso-formatter [pattern] (impl/value :jolt.time/dt-formatter {:pattern pattern :locale "en" :iso true})) (defn- fmt-pattern [f] (or (impl/field f :pattern) (let [[kind style] (impl/field f :style)] @@ -128,7 +140,7 @@ (declare parse-with-pattern parse-fields-strict parse-zoned-strict strict-parseable?) (__register-class-methods! :jolt.time/dt-formatter - {"format" (fn [self v] (format-pattern (fmt-pattern self) v (fmt-locale self))) + {"format" (fn [self v] (format-pattern (fmt-pattern self) v (fmt-locale self) (boolean (impl/field self :iso)))) "withLocale" (fn [self l] (impl/value :jolt.time/dt-formatter {:pattern (impl/field self :pattern) :style (impl/field self :style) @@ -258,16 +270,19 @@ (statics! ["DateTimeFormatter" "java.time.format.DateTimeFormatter"] {"ofPattern" (fn [p & r] (formatter (str p) (if (seq r) (locale-id (first r)) "en"))) - "ISO_LOCAL_DATE" (formatter "yyyy-MM-dd" "en") "ISO_LOCAL_TIME" (formatter "HH:mm:ss" "en") - "ISO_LOCAL_DATE_TIME" (formatter "yyyy-MM-dd'T'HH:mm:ss" "en") - "ISO_DATE" (formatter "yyyy-MM-dd" "en") "ISO_TIME" (formatter "HH:mm:ss" "en") - "ISO_DATE_TIME" (formatter "yyyy-MM-dd'T'HH:mm:ss" "en") - "ISO_INSTANT" (formatter "yyyy-MM-dd'T'HH:mm:ssX" "en") - "ISO_OFFSET_DATE_TIME" (formatter "yyyy-MM-dd'T'HH:mm:ssXXX" "en") - "ISO_OFFSET_TIME" (formatter "HH:mm:ssXXX" "en") + "ISO_LOCAL_DATE" (formatter "yyyy-MM-dd" "en") "ISO_LOCAL_TIME" (iso-formatter "HH:mm:ss") + ;; the ISO constants carry :iso, so the second's fraction prints as + ;; java.time's do (see iso-fraction); the strict parser already takes an + ;; optional fraction after the seconds when the pattern spells no S + "ISO_LOCAL_DATE_TIME" (iso-formatter "yyyy-MM-dd'T'HH:mm:ss") + "ISO_DATE" (formatter "yyyy-MM-dd" "en") "ISO_TIME" (iso-formatter "HH:mm:ss") + "ISO_DATE_TIME" (iso-formatter "yyyy-MM-dd'T'HH:mm:ss") + "ISO_INSTANT" (iso-formatter "yyyy-MM-dd'T'HH:mm:ssX") + "ISO_OFFSET_DATE_TIME" (iso-formatter "yyyy-MM-dd'T'HH:mm:ssXXX") + "ISO_OFFSET_TIME" (iso-formatter "HH:mm:ssXXX") "ISO_OFFSET_DATE" (formatter "yyyy-MM-ddXXX" "en") "ISO_ZONED_DATE_TIME" (impl/value :jolt.time/dt-formatter - {:pattern "yyyy-MM-dd'T'HH:mm:ss.SSSXXX'['VV']'" :locale "en" :iso :zoned}) + {:pattern "yyyy-MM-dd'T'HH:mm:ssXXX'['VV']'" :locale "en" :iso :zoned}) "ISO_ORDINAL_DATE" (formatter "yyyy-DDD" "en") "ISO_WEEK_DATE" (formatter "yyyy-'W'ww-e" "en") "BASIC_ISO_DATE" (formatter "yyyyMMdd" "en") diff --git a/test/jolt/time/fmt_test.clj b/test/jolt/time/fmt_test.clj index 62e8fb5..eb53ebe 100644 --- a/test/jolt/time/fmt_test.clj +++ b/test/jolt/time/fmt_test.clj @@ -16,6 +16,25 @@ (is (= "1:45 PM" (.format (DateTimeFormatter/ofPattern "h:mm a") (LocalDateTime/of 2020 3 5 13 45)))) (is (= "05/03/2020" (.format (DateTimeFormatter/ofPattern "dd/MM/yyyy") (LocalDate/of 2020 3 5))))) +;; java.time's ISO constants print the second's fraction only when it is +;; non-zero, with as many digits as carry a value (up to nine); values from +;; the reference. +(deftest iso-fraction + (let [z (java.time.ZoneId/of "Australia/Sydney") + zdt (fn [nano] (java.time.ZonedDateTime/of 2026 1 15 12 0 0 nano z)) + f DateTimeFormatter/ISO_ZONED_DATE_TIME] + (is (= "2026-01-15T12:00:00+11:00[Australia/Sydney]" (.format f (zdt 0)))) + (is (= "2026-01-15T12:00:00.123+11:00[Australia/Sydney]" (.format f (zdt 123000000)))) + (is (= "2026-01-15T12:00:00.123456789+11:00[Australia/Sydney]" (.format f (zdt 123456789)))) + (is (= "2026-01-15T12:00:00.00012+11:00[Australia/Sydney]" (.format f (zdt 120000)))) + (is (= "2026-01-15T12:00:00.005+11:00" (.format DateTimeFormatter/ISO_OFFSET_DATE_TIME (zdt 5000000)))) + (is (= "2026-01-15T12:00:00+11:00" (.format DateTimeFormatter/ISO_OFFSET_DATE_TIME (zdt 0)))) + (is (= "12:00:00.5" (.format DateTimeFormatter/ISO_LOCAL_TIME (java.time.LocalTime/of 12 0 0 500000000)))) + (is (= "2026-01-15T12:00:00" (.format DateTimeFormatter/ISO_LOCAL_DATE_TIME (LocalDateTime/of 2026 1 15 12 0 0)))) + ;; what it prints, it parses back + (is (= (zdt 123456789) (java.time.ZonedDateTime/parse (.format f (zdt 123456789))))) + (is (= (zdt 0) (java.time.ZonedDateTime/parse (.format f (zdt 0))))))) + (deftest iso-constants (is (= "2020-03-05" (.format DateTimeFormatter/ISO_LOCAL_DATE (LocalDate/of 2020 3 5)))) (is (= "13:45:30" (.format DateTimeFormatter/ISO_LOCAL_TIME (LocalDateTime/of 2020 3 5 13 45 30)))) From 7ee65f008b7fa51957620677002e2bb16010df23 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Tue, 29 Sep 2026 13:53:53 -0400 Subject: [PATCH 2/2] ISO_INSTANT prints its fraction in groups of three; withLocale keeps :iso ISO_INSTANT is .500Z and .000120Z on the JVM, not trimmed like the other ISO constants. withLocale rebuilt the formatter without its :iso flag, so a localized ISO constant lost its fraction and ISO_ZONED_DATE_TIME its zoned parse. --- src/jolt/time/fmt.clj | 27 +++++++++++++++++---------- test/jolt/time/fmt_test.clj | 13 ++++++++++++- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/jolt/time/fmt.clj b/src/jolt/time/fmt.clj index 8cd8762..ba50ba2 100644 --- a/src/jolt/time/fmt.clj +++ b/src/jolt/time/fmt.clj @@ -77,15 +77,19 @@ ;; The ISO_* formatters print the second's fraction the way java.time's ;; ISO_LOCAL_TIME does -- omitted when zero, else as many digits as carry a ;; value, up to nine -- and a pattern's fixed-width S never does; so the rule -;; is a flag on the formatter (`iso-frac?`) that the seconds letter reads. -(defn- iso-fraction [nano] +;; is a flag on the formatter (`iso-frac`) that the seconds letter reads. +;; ISO_INSTANT is the exception: it prints the fraction in whole groups of three +;; (".500", ".000120"), which is the :instant mode. +(defn- iso-fraction [nano mode] (if (zero? nano) "" - (let [s (u/pad-left (str nano) 9)] - (str "." (subs s 0 (inc (loop [i 8] (if (= \0 (nth s i)) (recur (dec i)) i)))))))) + (let [s (u/pad-left (str nano) 9) + last-digit (loop [i 8] (if (= \0 (nth s i)) (recur (dec i)) i)) + width (if (= :instant mode) (* 3 (quot (+ last-digit 3) 3)) (inc last-digit))] + (str "." (subs s 0 width))))) (defn format-pattern - ([pattern v locale] (format-pattern pattern v locale false)) - ([pattern v locale iso-frac?] + ([pattern v locale] (format-pattern pattern v locale nil)) + ([pattern v locale iso-frac] (let [p (parts v)] (if-not p (str v) (let [[y mo d hh mi se nano dow off zid] p n (count pattern)] @@ -104,7 +108,7 @@ (= c \H) (recur (+ i k) (str out (if (= k 1) (str hh) (u/pad2 hh)))) (= c \h) (recur (+ i k) (str out (let [h12 (let [h (mod hh 12)] (if (zero? h) 12 h))] (if (= k 1) (str h12) (u/pad2 h12))))) (= c \m) (recur (+ i k) (str out (if (= k 1) (str mi) (u/pad2 mi)))) - (= c \s) (recur (+ i k) (str out (if (= k 1) (str se) (u/pad2 se)) (if iso-frac? (iso-fraction nano) ""))) + (= c \s) (recur (+ i k) (str out (if (= k 1) (str se) (u/pad2 se)) (if iso-frac (iso-fraction nano iso-frac) ""))) (= c \S) (recur (+ i k) (str out (u/pad-left (str (quot nano (u/pow10 (max 0 (- 9 k))))) k))) (= c \a) (recur (+ i k) (str out (if (< hh 12) "AM" "PM"))) (= c \X) (recur (+ i k) (str out (off-iso off (>= k 3) true))) @@ -115,7 +119,9 @@ ;; --- DateTimeFormatter ------------------------------------------------------- (defn formatter [pattern locale] (impl/value :jolt.time/dt-formatter {:pattern pattern :locale (or locale "en")})) -(defn- iso-formatter [pattern] (impl/value :jolt.time/dt-formatter {:pattern pattern :locale "en" :iso true})) +(defn- iso-formatter + ([pattern] (iso-formatter pattern true)) + ([pattern mode] (impl/value :jolt.time/dt-formatter {:pattern pattern :locale "en" :iso mode}))) (defn- fmt-pattern [f] (or (impl/field f :pattern) (let [[kind style] (impl/field f :style)] @@ -140,10 +146,11 @@ (declare parse-with-pattern parse-fields-strict parse-zoned-strict strict-parseable?) (__register-class-methods! :jolt.time/dt-formatter - {"format" (fn [self v] (format-pattern (fmt-pattern self) v (fmt-locale self) (boolean (impl/field self :iso)))) + {"format" (fn [self v] (format-pattern (fmt-pattern self) v (fmt-locale self) (impl/field self :iso))) "withLocale" (fn [self l] (impl/value :jolt.time/dt-formatter {:pattern (impl/field self :pattern) :style (impl/field self :style) + :iso (impl/field self :iso) :locale (locale-id l)})) "withZone" (fn [self _z] self) "parse" (fn @@ -277,7 +284,7 @@ "ISO_LOCAL_DATE_TIME" (iso-formatter "yyyy-MM-dd'T'HH:mm:ss") "ISO_DATE" (formatter "yyyy-MM-dd" "en") "ISO_TIME" (iso-formatter "HH:mm:ss") "ISO_DATE_TIME" (iso-formatter "yyyy-MM-dd'T'HH:mm:ss") - "ISO_INSTANT" (iso-formatter "yyyy-MM-dd'T'HH:mm:ssX") + "ISO_INSTANT" (iso-formatter "yyyy-MM-dd'T'HH:mm:ssX" :instant) "ISO_OFFSET_DATE_TIME" (iso-formatter "yyyy-MM-dd'T'HH:mm:ssXXX") "ISO_OFFSET_TIME" (iso-formatter "HH:mm:ssXXX") "ISO_OFFSET_DATE" (formatter "yyyy-MM-ddXXX" "en") diff --git a/test/jolt/time/fmt_test.clj b/test/jolt/time/fmt_test.clj index eb53ebe..8aa39ee 100644 --- a/test/jolt/time/fmt_test.clj +++ b/test/jolt/time/fmt_test.clj @@ -33,7 +33,18 @@ (is (= "2026-01-15T12:00:00" (.format DateTimeFormatter/ISO_LOCAL_DATE_TIME (LocalDateTime/of 2026 1 15 12 0 0)))) ;; what it prints, it parses back (is (= (zdt 123456789) (java.time.ZonedDateTime/parse (.format f (zdt 123456789))))) - (is (= (zdt 0) (java.time.ZonedDateTime/parse (.format f (zdt 0))))))) + (is (= (zdt 0) (java.time.ZonedDateTime/parse (.format f (zdt 0)))))) + ;; ISO_INSTANT prints the fraction in groups of three digits, not trimmed + (is (= ["1970-01-01T00:00:00Z" "1970-01-01T00:00:00.500Z" "1970-01-01T00:00:00.000120Z" + "1970-01-01T00:00:00.123456789Z" "1970-01-01T00:00:00.005Z"] + (mapv #(.format DateTimeFormatter/ISO_INSTANT (java.time.Instant/ofEpochSecond 0 %)) + [0 500000000 120000 123456789 5000000]))) + ;; a locale does not change what an ISO constant is + (is (= "12:00:00.5" (.format (.withLocale DateTimeFormatter/ISO_LOCAL_TIME java.util.Locale/US) + (java.time.LocalTime/of 12 0 0 500000000)))) + (is (= "2026-01-15T12:00:00.5+11:00[Australia/Sydney]" + (.format (.withLocale DateTimeFormatter/ISO_ZONED_DATE_TIME java.util.Locale/US) + (java.time.ZonedDateTime/of 2026 1 15 12 0 0 500000000 (java.time.ZoneId/of "Australia/Sydney")))))) (deftest iso-constants (is (= "2020-03-05" (.format DateTimeFormatter/ISO_LOCAL_DATE (LocalDate/of 2020 3 5))))