From 27d99db4bce1225f8cef5004444370450e1840c5 Mon Sep 17 00:00:00 2001 From: Dmitri Sotnikov Date: Fri, 26 Jun 2026 19:49:49 +0000 Subject: [PATCH] java.time: parse fractional seconds in formatter-based date parsing (#240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse-ms ignored sub-second fractions: a formatter parse of "2020-07-06T10:59:13.417Z" dropped the .417 (and the ISO_OFFSET_DATE_TIME pattern "…ssXXX" then mis-aligned, reading ".417Z" as the offset). java.time's ISO formatters accept an optional fractional second, so jolt should too. After parsing the seconds field, consume an optional .fff from the input (to millis) when the pattern carries no fraction field — which is how the ISO_* constants are modeled here (ss, no S). Also handle the S pattern letter for explicit .SSS patterns. Carry the fraction into the result. Fixes aws-api shape iso8601 parse (1 FAIL -> 0; cognitect.aws.util/parse-date now returns the right Date). Two JVM-certified corpus rows. make test green, 0 new divergences. Runtime .ss — no re-mint. Co-authored-by: Yogthos --- host/chez/java/inst-time.ss | 24 +++++++++++++++++++++--- test/chez/corpus.edn | 2 ++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/host/chez/java/inst-time.ss b/host/chez/java/inst-time.ss index 51a7102..c65c743 100644 --- a/host/chez/java/inst-time.ss +++ b/host/chez/java/inst-time.ss @@ -179,7 +179,7 @@ (else (loop (+ i 1))))))) (define (parse-ms pattern input) (let ((pn (string-length pattern)) (inn (string-length input)) - (y 1970) (mo 1) (d 1) (hh 0) (mi 0) (ss 0) (pm 'none)) + (y 1970) (mo 1) (d 1) (hh 0) (mi 0) (ss 0) (frac-ms 0) (pm 'none)) ;; a parse failure is a java.time.format.DateTimeParseException (typed, so a ;; (catch DateTimeParseException …) over a bad date matches), like the JVM. (define (pfail) @@ -208,7 +208,7 @@ (begin (when (eq? pm 'pm) (when (< hh 12) (set! hh (+ hh 12)))) (when (eq? pm 'am) (when (= hh 12) (set! hh 0))) - (make-jinst (* 1000 (+ (* (days-from-civil y mo d) 86400) (* hh 3600) (* mi 60) ss)))) + (make-jinst (+ (* 1000 (+ (* (days-from-civil y mo d) 86400) (* hh 3600) (* mi 60) ss)) frac-ms))) (let ((c (string-ref pattern pi))) (cond ((char-alphabetic? c) @@ -225,7 +225,25 @@ ((char=? c #\d) (let ((r (read-digits-w ii (if (>= k 2) k #f)))) (set! d (car r)) (loop (+ pi k) (cdr r)))) ((or (char=? c #\H) (char=? c #\h)) (let ((r (read-digits-w ii (if (>= k 2) k #f)))) (set! hh (car r)) (loop (+ pi k) (cdr r)))) ((char=? c #\m) (let ((r (read-digits-w ii (if (>= k 2) k #f)))) (set! mi (car r)) (loop (+ pi k) (cdr r)))) - ((char=? c #\s) (let ((r (read-digits-w ii (if (>= k 2) k #f)))) (set! ss (car r)) (loop (+ pi k) (cdr r)))) + ((char=? c #\s) (let ((r (read-digits-w ii (if (>= k 2) k #f)))) + (set! ss (car r)) + ;; an ISO formatter (modeled here as an ss-pattern with no S + ;; field) still accepts an optional fractional second; consume + ;; .fff -> millis from the input. Skip when the pattern carries + ;; the fraction itself (a following '.'/S handles it). + (let ((j (cdr r)) (pnext (if (< (+ pi k) pn) (string-ref pattern (+ pi k)) #\nul))) + (if (and (not (char=? pnext #\.)) (not (char=? pnext #\S)) + (< j inn) (char=? (string-ref input j) #\.) + (< (+ j 1) inn) (digit? (string-ref input (+ j 1)))) + (let frac ((p (+ j 1)) (kk 0) (acc 0)) + (if (and (< p inn) (digit? (string-ref input p))) + (frac (+ p 1) (+ kk 1) (if (< kk 3) (+ (* acc 10) (- (char->integer (string-ref input p)) 48)) acc)) + (begin (set! frac-ms (* acc (expt 10 (max 0 (- 3 kk))))) (loop (+ pi k) p)))) + (loop (+ pi k) j))))) + ((char=? c #\S) (let frac ((p ii) (kk 0) (acc 0)) + (if (and (< p inn) (< kk k) (digit? (string-ref input p))) + (frac (+ p 1) (+ kk 1) (+ (* acc 10) (- (char->integer (string-ref input p)) 48))) + (begin (set! frac-ms (* acc (expt 10 (max 0 (- 3 kk))))) (loop (+ pi k) p))))) ((char=? c #\E) (loop (+ pi k) (cdr (read-alpha ii)))) ((char=? c #\a) (let ((r (read-alpha ii))) (set! pm (if (string=? (ascii-string-down (car r)) "pm") 'pm 'am)) diff --git a/test/chez/corpus.edn b/test/chez/corpus.edn index 4b7961d..0bfe430 100644 --- a/test/chez/corpus.edn +++ b/test/chez/corpus.edn @@ -558,6 +558,8 @@ {:suite "interop / java.time chrono-fields" :label "ALIGNED_WEEK_OF_MONTH" :expected "3" :actual "(.getLong (java.time.LocalDate/of 2020 3 17) java.time.temporal.ChronoField/ALIGNED_WEEK_OF_MONTH)"} {:suite "interop / java.time chrono-fields" :label "Year isSupported + getLong" :expected "2020" :actual "(.getLong (java.time.Year/of 2020) java.time.temporal.ChronoField/YEAR)"} {:suite "interop / java.time chrono-fields" :label "YearMonth PROLEPTIC_MONTH" :expected "24244" :actual "(.getLong (java.time.YearMonth/of 2020 5) java.time.temporal.ChronoField/PROLEPTIC_MONTH)"} + {:suite "interop / java.time chrono-fields" :label "ISO_OFFSET_DATE_TIME keeps fractional seconds" :expected "1594033153417" :actual "(.toEpochMilli (.toInstant (java.time.ZonedDateTime/parse \"2020-07-06T10:59:13.417Z\" java.time.format.DateTimeFormatter/ISO_OFFSET_DATE_TIME)))"} + {:suite "interop / java.time chrono-fields" :label "ISO_OFFSET_DATE_TIME without fraction" :expected "1594033153000" :actual "(.toEpochMilli (.toInstant (java.time.ZonedDateTime/parse \"2020-07-06T10:59:13Z\" java.time.format.DateTimeFormatter/ISO_OFFSET_DATE_TIME)))"} {:suite "interop / java.time shims" :label "Instant/now is current" :expected "true" :actual "(> (.toEpochMilli (Instant/now)) 1500000000000)"} {:suite "interop / java.time shims" :label "sql types are not" :expected "false" :actual "(instance? java.sql.Timestamp #inst \"2020-01-01T00:00:00Z\")"} {:suite "interop / StringReader & StringBuilder" :label "StringReader read" :expected "[97 98 -1]" :actual "(let [r (java.io.StringReader. \"ab\")] [(.read r) (.read r) (.read r)])"}