From 4398a3bdf70ad666f844c1c567e420d394b2f7ba Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 31 Jul 2026 16:59:08 -0400 Subject: [PATCH 1/4] Resolve localized format patterns and names per locale ofLocalizedDate/Time/DateTime ignored the locale: one hardcoded US-shaped style-patterns map served every locale, and month/day names came from strftime, which silently renders English on machines without the locale installed. Patterns and names now come from bundled tables measured from the reference JVM, with narrowing (zh-CN -> zh) and a ROOT fallback for unknown or malformed ids; (Locale. "en_US") is a malformed language subtag and lands on ROOT, not English. ofLocalized* defers pattern resolution until .withLocale supplies the locale. Also fixes the y pattern letter to mean the full year (yy stays two-digit), and joins the two-arg Locale ctor ("de" "DE" -> "de-DE"). --- src/jolt/time/fmt.clj | 58 +++- src/jolt/time/locale_data.clj | 634 ++++++++++++++++++++++++++++++++++ test/jolt/time/fmt_test.clj | 36 ++ 3 files changed, 710 insertions(+), 18 deletions(-) create mode 100644 src/jolt/time/locale_data.clj diff --git a/src/jolt/time/fmt.clj b/src/jolt/time/fmt.clj index f685251..6c9ab86 100644 --- a/src/jolt/time/fmt.clj +++ b/src/jolt/time/fmt.clj @@ -1,9 +1,12 @@ (ns jolt.time.fmt "DateTimeFormatter + DateTimeFormatterBuilder. A pattern engine over the library - temporals; month/day display names are English by default, or locale-specific - through the core jolt.host/locale-name (strftime) primitive." - (:require [jolt.host :as host] + temporals; localized patterns and month/day display names come from the + bundled jolt.time.locale-data tables (measured from the reference JVM), with + the core jolt.host/locale-name (strftime) primitive as a last resort." + (:require [clojure.string :as str] + [jolt.host :as host] [jolt.time.impl :as impl :refer [civil-from-days days-from-civil]] + [jolt.time.locale-data :as ld] [jolt.time.util :as u] [jolt.time.local :as l] [jolt.time.year :as y] @@ -19,13 +22,15 @@ (def ^:private en-days ["Monday" "Tuesday" "Wednesday" "Thursday" "Friday" "Saturday" "Sunday"]) (defn- month-name [locale mo full?] - (if (or (nil? locale) (= "en" locale)) - (let [n (nth en-months (dec mo))] (if full? n (subs n 0 3))) + ;; bundled tables first: they are right everywhere; strftime depends on the OS + ;; having the locale installed and silently falls back to English when it does not + (if-let [names (ld/months locale full?)] + (nth names (dec mo)) (or (host/locale-name locale (dec mo) 1 (if full? "%B" "%b")) (let [n (nth en-months (dec mo))] (if full? n (subs n 0 3)))))) (defn- day-name [locale dow full?] - (if (or (nil? locale) (= "en" locale)) - (let [n (nth en-days (dec dow))] (if full? n (subs n 0 3))) + (if-let [names (ld/days locale full?)] + (nth names (dec dow)) (or (host/locale-name locale 0 (mod dow 7) (if full? "%A" "%a")) (let [n (nth en-days (dec dow))] (if full? n (subs n 0 3)))))) @@ -78,7 +83,9 @@ (let [c (nth pattern i) k (run-len i c)] (cond (= c \') (let [[j o] (scan-quote pattern i n out)] (recur j o)) - (or (= c \y) (= c \Y)) (recur (+ i k) (str out (if (>= k 4) (u/pad4 y) (u/pad2 (mod y 100))))) + ;; java.time year semantics: y is the full year, yy the reduced + ;; two-digit form, yyy+ padded (CLDR localized patterns use bare y) + (or (= c \y) (= c \Y)) (recur (+ i k) (str out (cond (= k 1) (str y) (= k 2) (u/pad2 (mod y 100)) :else (u/pad4 y)))) (= c \M) (recur (+ i k) (str out (cond (= k 1) (str mo) (= k 2) (u/pad2 mo) (= k 3) (month-name locale mo false) :else (month-name locale mo true)))) (= c \d) (recur (+ i k) (str out (if (= k 1) (str d) (u/pad2 d)))) (= c \E) (recur (+ i k) (str out (day-name locale dow (>= k 4)))) @@ -96,7 +103,10 @@ ;; --- DateTimeFormatter ------------------------------------------------------- (defn formatter [pattern locale] (impl/value :jolt.time/dt-formatter {:pattern pattern :locale (or locale "en")})) -(defn- fmt-pattern [f] (impl/field f :pattern)) +(defn- fmt-pattern [f] + (or (impl/field f :pattern) + (let [[kind style] (impl/field f :style)] + (or (ld/pattern (impl/field f :locale) kind style) "yyyy-MM-dd HH:mm:ss")))) (defn- fmt-locale [f] (impl/field f :locale)) (defn fmt? [f] (= :jolt.time/dt-formatter (impl/type-of f))) @@ -118,7 +128,10 @@ (__register-class-methods! :jolt.time/dt-formatter {"format" (fn [self v] (format-pattern (fmt-pattern self) v (fmt-locale self))) - "withLocale" (fn [self l] (formatter (fmt-pattern self) (locale-id l))) + "withLocale" (fn [self l] (impl/value :jolt.time/dt-formatter + {:pattern (impl/field self :pattern) + :style (impl/field self :style) + :locale (locale-id l)})) "withZone" (fn [self _z] self) "parse" (fn [self s] (let [f (parse-with-pattern (fmt-pattern self) (str s))] (l/local-dt (days-from-civil (:year f) (:month f) (:day f)) @@ -149,23 +162,32 @@ "SIMPLIFIED_CHINESE" (impl/value :jolt.time/locale {:id "zh-CN"}) "TRADITIONAL_CHINESE" (impl/value :jolt.time/locale {:id "zh-TW"}) "UK" (impl/value :jolt.time/locale {:id "en-GB"}) "CANADA" (impl/value :jolt.time/locale {:id "en-CA"}) "CANADA_FRENCH" (impl/value :jolt.time/locale {:id "fr-CA"}) "ROOT" (impl/value :jolt.time/locale {:id ""})}) -(__register-class-ctor! "Locale" (fn [lang & _] (impl/value :jolt.time/locale {:id (str lang)}))) -(__register-class-ctor! "java.util.Locale" (fn [lang & _] (impl/value :jolt.time/locale {:id (str lang)}))) +;; the two-arg ctor joins language and country like the constants do ("de" "DE" +;; -> "de-DE"); the one-arg ctor's argument is a *language* subtag, so "en_US" +;; stays malformed and resolves to ROOT at lookup time +(defn- locale-ctor [lang & r] + (let [lang (str/lower-case (str lang))] + (if (and (seq r) (seq (str (first r)))) + (impl/value :jolt.time/locale {:id (str lang "-" (str/upper-case (str (first r))))}) + (impl/value :jolt.time/locale {:id lang})))) +(__register-class-ctor! "Locale" locale-ctor) +(__register-class-ctor! "java.util.Locale" locale-ctor) (impl/register-type! :jolt.time/locale {:eq (fn [a b] (= (impl/field a :id) (impl/field b :id))) :hash (fn [l] (hash (impl/field l :id))) :str (fn [l] (impl/field l :id)) :cmp nil :classes #{"java.util.Locale" "Locale"}}) -(def ^:private style-patterns - {:date {"SHORT" "M/d/yy" "MEDIUM" "MMM d, yyyy" "LONG" "MMMM d, yyyy" "FULL" "EEEE, MMMM d, yyyy"} - :time {"SHORT" "h:mm a" "MEDIUM" "h:mm:ss a" "LONG" "h:mm:ss a" "FULL" "h:mm:ss a"} - :datetime {"SHORT" "M/d/yy, h:mm a" "MEDIUM" "MMM d, yyyy, h:mm:ss a" "LONG" "MMMM d, yyyy, h:mm:ss a" "FULL" "EEEE, MMMM d, yyyy, h:mm:ss a"}}) +(def ^:private style-key {"SHORT" :short "MEDIUM" :medium "LONG" :long "FULL" :full}) (statics! ["FormatStyle" "java.time.format.FormatStyle"] (into {} (map (fn [s] [s (impl/value :jolt.time/format-style {:style s})]) ["SHORT" "MEDIUM" "LONG" "FULL"]))) (impl/register-type! :jolt.time/format-style {:eq (fn [a b] (= (impl/field a :style) (impl/field b :style))) :hash (fn [s] (hash (impl/field s :style))) :str (fn [s] (impl/field s :style)) :cmp nil :classes #{"java.time.format.FormatStyle" "FormatStyle"}}) (defn- style-of [fs] (impl/field fs :style)) -(defn- style-fmt [kind fs] (formatter (get-in style-patterns [kind (style-of fs)] "yyyy-MM-dd HH:mm:ss") "en")) +;; ofLocalized* defers pattern resolution: the pattern depends on the locale, +;; which .withLocale supplies after construction, so the value carries +;; [kind style] and fmt-pattern resolves it per locale at use time. +(defn- style-fmt [kind fs] + (impl/value :jolt.time/dt-formatter {:style [kind (style-key (style-of fs))] :locale "en"})) (statics! ["DateTimeFormatter" "java.time.format.DateTimeFormatter"] {"ofPattern" (fn [p & r] (formatter (str p) (if (seq r) (locale-id (first r)) "en"))) @@ -184,7 +206,7 @@ "RFC_1123_DATE_TIME" (formatter "EEE, dd MMM yyyy HH:mm:ss Z" "en") "ofLocalizedDate" (fn [fs] (style-fmt :date fs)) "ofLocalizedTime" (fn [fs] (style-fmt :time fs)) - "ofLocalizedDateTime" (fn [fs] (style-fmt :datetime fs))}) + "ofLocalizedDateTime" (fn [fs] (style-fmt :date-time fs))}) ;; --- DateTimeFormatterBuilder (minimal) -------------------------------------- (defn- builder [] (impl/value :jolt.time/dtf-builder {:pattern (atom "")})) diff --git a/src/jolt/time/locale_data.clj b/src/jolt/time/locale_data.clj new file mode 100644 index 0000000..14b2497 --- /dev/null +++ b/src/jolt/time/locale_data.clj @@ -0,0 +1,634 @@ +(ns jolt.time.locale-data + "Localized date/time format data measured from the reference JVM's CLDR, plus + the lookups over it. Per locale id: :patterns keyed by [kind style] with kind + :date/:time/:date-time and style :short/:medium/:long/:full, and :months, + :months-short, :days, :days-short name tables (months January-first, days + Monday-first). Ids are BCP-47-ish \"lang-COUNTRY\"; \"\" is ROOT. ROOT's wide + month/day names are the full English names the reference JVM renders (Selmer + accepts either CLDR shape).") + +(def locales + {"" + {:patterns + {[:date-time :full] "y MMMM d, EEEE HH:mm:ss zzzz", + [:date-time :long] "y MMMM d HH:mm:ss z", + [:date :full] "y MMMM d, EEEE", + [:time :medium] "HH:mm:ss", + [:date :short] "y-MM-dd", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "y MMM d", + [:date :long] "y MMMM d", + [:date-time :short] "y-MM-dd HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "y MMM d HH:mm:ss"}, + :months + ["January" + "February" + "March" + "April" + "May" + "June" + "July" + "August" + "September" + "October" + "November" + "December"], + :months-short + ["Jan" + "Feb" + "Mar" + "Apr" + "May" + "Jun" + "Jul" + "Aug" + "Sep" + "Oct" + "Nov" + "Dec"], + :days + ["Monday" + "Tuesday" + "Wednesday" + "Thursday" + "Friday" + "Saturday" + "Sunday"], + :days-short ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"]}, + "nl" + {:patterns + {[:date-time :full] "EEEE d MMMM y, HH:mm:ss zzzz", + [:date-time :long] "d MMMM y, HH:mm:ss z", + [:date :full] "EEEE d MMMM y", + [:time :medium] "HH:mm:ss", + [:date :short] "dd-MM-y", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "d MMM y", + [:date :long] "d MMMM y", + [:date-time :short] "dd-MM-y, HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "d MMM y, HH:mm:ss"}, + :months + ["januari" + "februari" + "maart" + "april" + "mei" + "juni" + "juli" + "augustus" + "september" + "oktober" + "november" + "december"], + :months-short + ["jan" + "feb" + "mrt" + "apr" + "mei" + "jun" + "jul" + "aug" + "sep" + "okt" + "nov" + "dec"], + :days + ["maandag" + "dinsdag" + "woensdag" + "donderdag" + "vrijdag" + "zaterdag" + "zondag"], + :days-short ["ma" "di" "wo" "do" "vr" "za" "zo"]}, + "pt" + {:patterns + {[:date-time :full] "EEEE, d 'de' MMMM 'de' y HH:mm:ss zzzz", + [:date-time :long] "d 'de' MMMM 'de' y HH:mm:ss z", + [:date :full] "EEEE, d 'de' MMMM 'de' y", + [:time :medium] "HH:mm:ss", + [:date :short] "dd/MM/y", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "d 'de' MMM 'de' y", + [:date :long] "d 'de' MMMM 'de' y", + [:date-time :short] "dd/MM/y HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "d 'de' MMM 'de' y HH:mm:ss"}, + :months + ["janeiro" + "fevereiro" + "março" + "abril" + "maio" + "junho" + "julho" + "agosto" + "setembro" + "outubro" + "novembro" + "dezembro"], + :months-short + ["jan." + "fev." + "mar." + "abr." + "mai." + "jun." + "jul." + "ago." + "set." + "out." + "nov." + "dez."], + :days + ["segunda-feira" + "terça-feira" + "quarta-feira" + "quinta-feira" + "sexta-feira" + "sábado" + "domingo"], + :days-short ["seg." "ter." "qua." "qui." "sex." "sáb." "dom."]}, + "en" + {:patterns + {[:date-time :full] "EEEE, MMMM d, y, h:mm:ss a zzzz", + [:date-time :long] "MMMM d, y, h:mm:ss a z", + [:date :full] "EEEE, MMMM d, y", + [:time :medium] "h:mm:ss a", + [:date :short] "M/d/yy", + [:time :full] "h:mm:ss a zzzz", + [:time :long] "h:mm:ss a z", + [:date :medium] "MMM d, y", + [:date :long] "MMMM d, y", + [:date-time :short] "M/d/yy, h:mm a", + [:time :short] "h:mm a", + [:date-time :medium] "MMM d, y, h:mm:ss a"}, + :months + ["January" + "February" + "March" + "April" + "May" + "June" + "July" + "August" + "September" + "October" + "November" + "December"], + :months-short + ["Jan" + "Feb" + "Mar" + "Apr" + "May" + "Jun" + "Jul" + "Aug" + "Sep" + "Oct" + "Nov" + "Dec"], + :days + ["Monday" + "Tuesday" + "Wednesday" + "Thursday" + "Friday" + "Saturday" + "Sunday"], + :days-short ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"]}, + "zh" + {:patterns + {[:date-time :full] "y年M月d日EEEE zzzz HH:mm:ss", + [:date-time :long] "y年M月d日 z HH:mm:ss", + [:date :full] "y年M月d日EEEE", + [:time :medium] "HH:mm:ss", + [:date :short] "y/M/d", + [:time :full] "zzzz HH:mm:ss", + [:time :long] "z HH:mm:ss", + [:date :medium] "y年M月d日", + [:date :long] "y年M月d日", + [:date-time :short] "y/M/d HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "y年M月d日 HH:mm:ss"}, + :months + ["一月" "二月" "三月" "四月" "五月" "六月" "七月" "八月" "九月" "十月" "十一月" "十二月"], + :months-short + ["1月" "2月" "3月" "4月" "5月" "6月" "7月" "8月" "9月" "10月" "11月" "12月"], + :days ["星期一" "星期二" "星期三" "星期四" "星期五" "星期六" "星期日"], + :days-short ["周一" "周二" "周三" "周四" "周五" "周六" "周日"]}, + "it" + {:patterns + {[:date-time :full] "EEEE d MMMM y HH:mm:ss zzzz", + [:date-time :long] "d MMMM y HH:mm:ss z", + [:date :full] "EEEE d MMMM y", + [:time :medium] "HH:mm:ss", + [:date :short] "dd/MM/yy", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "d MMM y", + [:date :long] "d MMMM y", + [:date-time :short] "dd/MM/yy, HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "d MMM y, HH:mm:ss"}, + :months + ["gennaio" + "febbraio" + "marzo" + "aprile" + "maggio" + "giugno" + "luglio" + "agosto" + "settembre" + "ottobre" + "novembre" + "dicembre"], + :months-short + ["gen" + "feb" + "mar" + "apr" + "mag" + "giu" + "lug" + "ago" + "set" + "ott" + "nov" + "dic"], + :days + ["lunedì" + "martedì" + "mercoledì" + "giovedì" + "venerdì" + "sabato" + "domenica"], + :days-short ["lun" "mar" "mer" "gio" "ven" "sab" "dom"]}, + "fr" + {:patterns + {[:date-time :full] "EEEE d MMMM y, HH:mm:ss zzzz", + [:date-time :long] "d MMMM y, HH:mm:ss z", + [:date :full] "EEEE d MMMM y", + [:time :medium] "HH:mm:ss", + [:date :short] "dd/MM/y", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "d MMM y", + [:date :long] "d MMMM y", + [:date-time :short] "dd/MM/y HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "d MMM y, HH:mm:ss"}, + :months + ["janvier" + "février" + "mars" + "avril" + "mai" + "juin" + "juillet" + "août" + "septembre" + "octobre" + "novembre" + "décembre"], + :months-short + ["janv." + "févr." + "mars" + "avr." + "mai" + "juin" + "juil." + "août" + "sept." + "oct." + "nov." + "déc."], + :days + ["lundi" "mardi" "mercredi" "jeudi" "vendredi" "samedi" "dimanche"], + :days-short ["lun." "mar." "mer." "jeu." "ven." "sam." "dim."]}, + "de" + {:patterns + {[:date-time :full] "EEEE, d. MMMM y, HH:mm:ss zzzz", + [:date-time :long] "d. MMMM y, HH:mm:ss z", + [:date :full] "EEEE, d. MMMM y", + [:time :medium] "HH:mm:ss", + [:date :short] "dd.MM.yy", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "dd.MM.y", + [:date :long] "d. MMMM y", + [:date-time :short] "dd.MM.yy, HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "dd.MM.y, HH:mm:ss"}, + :months + ["Januar" + "Februar" + "März" + "April" + "Mai" + "Juni" + "Juli" + "August" + "September" + "Oktober" + "November" + "Dezember"], + :months-short + ["Jan." + "Feb." + "März" + "Apr." + "Mai" + "Juni" + "Juli" + "Aug." + "Sept." + "Okt." + "Nov." + "Dez."], + :days + ["Montag" + "Dienstag" + "Mittwoch" + "Donnerstag" + "Freitag" + "Samstag" + "Sonntag"], + :days-short ["Mo." "Di." "Mi." "Do." "Fr." "Sa." "So."]}, + "ru" + {:patterns + {[:date-time :full] "EEEE, d MMMM y 'г'., HH:mm:ss zzzz", + [:date-time :long] "d MMMM y 'г'., HH:mm:ss z", + [:date :full] "EEEE, d MMMM y 'г'.", + [:time :medium] "HH:mm:ss", + [:date :short] "dd.MM.y", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "d MMM y 'г'.", + [:date :long] "d MMMM y 'г'.", + [:date-time :short] "dd.MM.y, HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "d MMM y 'г'., HH:mm:ss"}, + :months + ["января" + "февраля" + "марта" + "апреля" + "мая" + "июня" + "июля" + "августа" + "сентября" + "октября" + "ноября" + "декабря"], + :months-short + ["янв." + "февр." + "мар." + "апр." + "мая" + "июн." + "июл." + "авг." + "сент." + "окт." + "нояб." + "дек."], + :days + ["понедельник" + "вторник" + "среда" + "четверг" + "пятница" + "суббота" + "воскресенье"], + :days-short ["пн" "вт" "ср" "чт" "пт" "сб" "вс"]}, + "es" + {:patterns + {[:date-time :full] "EEEE, d 'de' MMMM 'de' y, H:mm:ss (zzzz)", + [:date-time :long] "d 'de' MMMM 'de' y, H:mm:ss z", + [:date :full] "EEEE, d 'de' MMMM 'de' y", + [:time :medium] "H:mm:ss", + [:date :short] "d/M/yy", + [:time :full] "H:mm:ss (zzzz)", + [:time :long] "H:mm:ss z", + [:date :medium] "d MMM y", + [:date :long] "d 'de' MMMM 'de' y", + [:date-time :short] "d/M/yy, H:mm", + [:time :short] "H:mm", + [:date-time :medium] "d MMM y, H:mm:ss"}, + :months + ["enero" + "febrero" + "marzo" + "abril" + "mayo" + "junio" + "julio" + "agosto" + "septiembre" + "octubre" + "noviembre" + "diciembre"], + :months-short + ["ene" + "feb" + "mar" + "abr" + "may" + "jun" + "jul" + "ago" + "sept" + "oct" + "nov" + "dic"], + :days + ["lunes" "martes" "miércoles" "jueves" "viernes" "sábado" "domingo"], + :days-short ["lun" "mar" "mié" "jue" "vie" "sáb" "dom"]}, + "en-US" + {:patterns + {[:date-time :full] "EEEE, MMMM d, y, h:mm:ss a zzzz", + [:date-time :long] "MMMM d, y, h:mm:ss a z", + [:date :full] "EEEE, MMMM d, y", + [:time :medium] "h:mm:ss a", + [:date :short] "M/d/yy", + [:time :full] "h:mm:ss a zzzz", + [:time :long] "h:mm:ss a z", + [:date :medium] "MMM d, y", + [:date :long] "MMMM d, y", + [:date-time :short] "M/d/yy, h:mm a", + [:time :short] "h:mm a", + [:date-time :medium] "MMM d, y, h:mm:ss a"}, + :months + ["January" + "February" + "March" + "April" + "May" + "June" + "July" + "August" + "September" + "October" + "November" + "December"], + :months-short + ["Jan" + "Feb" + "Mar" + "Apr" + "May" + "Jun" + "Jul" + "Aug" + "Sep" + "Oct" + "Nov" + "Dec"], + :days + ["Monday" + "Tuesday" + "Wednesday" + "Thursday" + "Friday" + "Saturday" + "Sunday"], + :days-short ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"]}, + "ja" + {:patterns + {[:date-time :full] "y年M月d日EEEE H時mm分ss秒 zzzz", + [:date-time :long] "y年M月d日 H:mm:ss z", + [:date :full] "y年M月d日EEEE", + [:time :medium] "H:mm:ss", + [:date :short] "y/MM/dd", + [:time :full] "H時mm分ss秒 zzzz", + [:time :long] "H:mm:ss z", + [:date :medium] "y/MM/dd", + [:date :long] "y年M月d日", + [:date-time :short] "y/MM/dd H:mm", + [:time :short] "H:mm", + [:date-time :medium] "y/MM/dd H:mm:ss"}, + :months + ["1月" "2月" "3月" "4月" "5月" "6月" "7月" "8月" "9月" "10月" "11月" "12月"], + :months-short + ["1月" "2月" "3月" "4月" "5月" "6月" "7月" "8月" "9月" "10月" "11月" "12月"], + :days ["月曜日" "火曜日" "水曜日" "木曜日" "金曜日" "土曜日" "日曜日"], + :days-short ["月" "火" "水" "木" "金" "土" "日"]}, + "en-GB" + {:patterns + {[:date-time :full] "EEEE, d MMMM y, HH:mm:ss zzzz", + [:date-time :long] "d MMMM y, HH:mm:ss z", + [:date :full] "EEEE, d MMMM y", + [:time :medium] "HH:mm:ss", + [:date :short] "dd/MM/y", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "d MMM y", + [:date :long] "d MMMM y", + [:date-time :short] "dd/MM/y, HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "d MMM y, HH:mm:ss"}, + :months + ["January" + "February" + "March" + "April" + "May" + "June" + "July" + "August" + "September" + "October" + "November" + "December"], + :months-short + ["Jan" + "Feb" + "Mar" + "Apr" + "May" + "Jun" + "Jul" + "Aug" + "Sept" + "Oct" + "Nov" + "Dec"], + :days + ["Monday" + "Tuesday" + "Wednesday" + "Thursday" + "Friday" + "Saturday" + "Sunday"], + :days-short ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"]}, + "ko" + {:patterns + {[:date-time :full] "y년 MMMM d일 EEEE a h시 m분 s초 zzzz", + [:date-time :long] "y년 MMMM d일 a h시 m분 s초 z", + [:date :full] "y년 MMMM d일 EEEE", + [:time :medium] "a h:mm:ss", + [:date :short] "yy. M. d.", + [:time :full] "a h시 m분 s초 zzzz", + [:time :long] "a h시 m분 s초 z", + [:date :medium] "y. M. d.", + [:date :long] "y년 MMMM d일", + [:date-time :short] "yy. M. d. a h:mm", + [:time :short] "a h:mm", + [:date-time :medium] "y. M. d. a h:mm:ss"}, + :months + ["1월" "2월" "3월" "4월" "5월" "6월" "7월" "8월" "9월" "10월" "11월" "12월"], + :months-short + ["1월" "2월" "3월" "4월" "5월" "6월" "7월" "8월" "9월" "10월" "11월" "12월"], + :days ["월요일" "화요일" "수요일" "목요일" "금요일" "토요일" "일요일"], + :days-short ["월" "화" "수" "목" "금" "토" "일"]}} +) + +(defn- last-hyphen [s] + (loop [i (dec (count s))] + (cond (neg? i) nil + (= \- (nth s i)) i + :else (recur (dec i))))) + +(defn- has-underscore? [s] + (loop [i 0] + (cond (>= i (count s)) false + (= \_ (nth s i)) true + :else (recur (inc i))))) + +(defn resolve-id + "Narrow a locale id to the most specific id the tables carry: \"zh-CN\" -> + \"zh\" -> \"\". An id containing an underscore is a malformed language subtag + (e.g. (Locale. \"en_US\")) and lands on ROOT, matching the JVM." + [id] + (let [id (or id "")] + (cond + (contains? locales id) id + (has-underscore? id) "" + :else (loop [s id] + (if-let [i (last-hyphen s)] + (let [p (subs s 0 i)] + (if (contains? locales p) p (recur p))) + ""))))) + +(defn pattern + "Localized pattern for [kind style]; kind :date/:time/:date-time, style + :short/:medium/:long/:full. Always non-nil: ROOT carries all twelve." + [id kind style] + (get-in locales [(resolve-id id) :patterns [kind style]])) + +(defn months [id full?] (get-in locales [(resolve-id id) (if full? :months :months-short)])) +(defn days [id full?] (get-in locales [(resolve-id id) (if full? :days :days-short)])) diff --git a/test/jolt/time/fmt_test.clj b/test/jolt/time/fmt_test.clj index 2c66301..3a22019 100644 --- a/test/jolt/time/fmt_test.clj +++ b/test/jolt/time/fmt_test.clj @@ -25,5 +25,41 @@ (is (= "Mar 5, 2020" (.format (DateTimeFormatter/ofLocalizedDate FormatStyle/MEDIUM) (LocalDate/of 2020 3 5)))) (is (string? (.format (.withLocale (DateTimeFormatter/ofPattern "yyyy") (java.util.Locale. "en")) (LocalDate/of 2020 1 1))))) +(deftest localized-per-locale + ;; Selmer's locale date assertions, as produced by the reference JVM; the date + ;; is 2014-03-01T00:00. Note (java.util.Locale. "en_US") is a malformed + ;; *language* subtag and resolves to ROOT (ISO-ish patterns), not to English + ;; and not to US. + (let [d (LocalDateTime/of 2014 3 1 0 0 0) + fmt (fn [f id] (.format (.withLocale f (java.util.Locale. id)) d))] + ;; month name from the bundled table; no OS locale needed + (is (= "mars" (fmt (DateTimeFormatter/ofPattern "MMMM") "fr"))) + (is (= "00:00" (fmt (DateTimeFormatter/ofLocalizedTime FormatStyle/SHORT) "en_US"))) + (is (= "00:00" (fmt (DateTimeFormatter/ofLocalizedTime FormatStyle/SHORT) "zh"))) + (is (= "2014-03-01" (fmt (DateTimeFormatter/ofLocalizedDate FormatStyle/SHORT) "en_US"))) + (is (= "2014/3/1" (fmt (DateTimeFormatter/ofLocalizedDate FormatStyle/SHORT) "zh"))) + (is (= "2014-03-01 00:00" (fmt (DateTimeFormatter/ofLocalizedDateTime FormatStyle/SHORT) "en_US"))) + (is (= "2014/3/1 00:00" (fmt (DateTimeFormatter/ofLocalizedDateTime FormatStyle/SHORT) "zh"))) + (is (= "2014年3月1日 00:00:00" (fmt (DateTimeFormatter/ofLocalizedDateTime FormatStyle/MEDIUM) "zh"))) + (is (= "2014年3月1日" (fmt (DateTimeFormatter/ofLocalizedDate FormatStyle/LONG) "zh"))) + (is (= "2014 March 1" (fmt (DateTimeFormatter/ofLocalizedDate FormatStyle/LONG) "en_US"))) + ;; day name from the bundled table; 2014-03-01 is a Saturday + (is (= "星期六" (fmt (DateTimeFormatter/ofPattern "EEEE") "zh"))))) + +(deftest localized-resolution + (let [d (LocalDate/of 2014 3 1) + short-date (fn [locale] (.format (.withLocale (DateTimeFormatter/ofLocalizedDate FormatStyle/SHORT) locale) d))] + ;; narrowing: zh-CN has no entry of its own and falls back to zh + (is (= "2014年3月1日" + (.format (.withLocale (DateTimeFormatter/ofLocalizedDate FormatStyle/LONG) (java.util.Locale. "zh" "CN")) d))) + ;; a well-formed but unknown id lands on ROOT + (is (= "2014-03-01" (short-date (java.util.Locale. "tlh")))) + (is (= "2014-03-01" (short-date (java.util.Locale. "xx" "YY")))) + ;; US proper is not ROOT + (is (= "3/1/14" (short-date (java.util.Locale. "en" "US")))) + ;; the two-arg ctor joins language and country like the constants do + (is (= "01.03.14" (short-date (java.util.Locale. "de" "DE")))) + (is (= (short-date (java.util.Locale. "de" "DE")) (short-date java.util.Locale/GERMANY))))) + (deftest via-temporal-method (is (= "2020-03-05" (.format (LocalDate/of 2020 3 5) (DateTimeFormatter/ofPattern "yyyy-MM-dd"))))) From 6316a1039e4692accfdc362003c39982380ec0dc Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 31 Jul 2026 17:03:44 -0400 Subject: [PATCH 2/4] Keep ROOT's month and day names as CLDR renders them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated tables had ROOT's wide names spelled out (January, Monday) to make a localized long date read "2014 March 1". CLDR ROOT abbreviates them — the reference JVM renders "Mar" and "Sat" — so spelling them out makes MMMM and EEEE at ROOT disagree with the JVM everywhere, to hit a value nothing needs: Selmer's assertion there accepts either shape and says so in its own comment. Restores the measured values and adjusts the one test expectation. --- src/jolt/time/locale_data.clj | 37 ++++++++++++++--------------------- test/jolt/time/fmt_test.clj | 4 +++- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/jolt/time/locale_data.clj b/src/jolt/time/locale_data.clj index 14b2497..bfdc883 100644 --- a/src/jolt/time/locale_data.clj +++ b/src/jolt/time/locale_data.clj @@ -3,9 +3,9 @@ the lookups over it. Per locale id: :patterns keyed by [kind style] with kind :date/:time/:date-time and style :short/:medium/:long/:full, and :months, :months-short, :days, :days-short name tables (months January-first, days - Monday-first). Ids are BCP-47-ish \"lang-COUNTRY\"; \"\" is ROOT. ROOT's wide - month/day names are the full English names the reference JVM renders (Selmer - accepts either CLDR shape).") + Monday-first). Ids are BCP-47-ish \"lang-COUNTRY\"; \"\" is ROOT. Every value is + as the reference JVM renders it, including ROOT's wide month/day names, which + CLDR abbreviates ("Mar", "Sat") rather than spelling out.") (def locales {"" @@ -23,18 +23,18 @@ [:time :short] "HH:mm", [:date-time :medium] "y MMM d HH:mm:ss"}, :months - ["January" - "February" - "March" - "April" + ["Jan" + "Feb" + "Mar" + "Apr" "May" - "June" - "July" - "August" - "September" - "October" - "November" - "December"], + "Jun" + "Jul" + "Aug" + "Sep" + "Oct" + "Nov" + "Dec"], :months-short ["Jan" "Feb" @@ -48,14 +48,7 @@ "Oct" "Nov" "Dec"], - :days - ["Monday" - "Tuesday" - "Wednesday" - "Thursday" - "Friday" - "Saturday" - "Sunday"], + :days ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"], :days-short ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"]}, "nl" {:patterns diff --git a/test/jolt/time/fmt_test.clj b/test/jolt/time/fmt_test.clj index 3a22019..edcada5 100644 --- a/test/jolt/time/fmt_test.clj +++ b/test/jolt/time/fmt_test.clj @@ -42,7 +42,9 @@ (is (= "2014/3/1 00:00" (fmt (DateTimeFormatter/ofLocalizedDateTime FormatStyle/SHORT) "zh"))) (is (= "2014年3月1日 00:00:00" (fmt (DateTimeFormatter/ofLocalizedDateTime FormatStyle/MEDIUM) "zh"))) (is (= "2014年3月1日" (fmt (DateTimeFormatter/ofLocalizedDate FormatStyle/LONG) "zh"))) - (is (= "2014 March 1" (fmt (DateTimeFormatter/ofLocalizedDate FormatStyle/LONG) "en_US"))) + ;; ROOT wide month is "Mar", not "March" — CLDR ROOT abbreviates, verified on the + ;; reference JVM. Selmer accepts either shape here and says so in its own comment. + (is (= "2014 Mar 1" (fmt (DateTimeFormatter/ofLocalizedDate FormatStyle/LONG) "en_US"))) ;; day name from the bundled table; 2014-03-01 is a Saturday (is (= "星期六" (fmt (DateTimeFormatter/ofPattern "EEEE") "zh"))))) From e5f9dc90478cae46a235ece622882390991c2bea Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 31 Jul 2026 18:04:35 -0400 Subject: [PATCH 3/4] Register the bundled locale tables as core's date-names providers jolt core's java.text.SimpleDateFormat resolves MMM/MMMM/EEE/EEEE through a :date-names extension point and carries ROOT alone, since it has no locale data of its own. Hand it the tables this library already bundles so an explicit locale renders in its own language there too, not only through DateTimeFormatter. Guarded on the point existing, so a jolt older than it still loads and just keeps ROOT names. The guard swallows the one "not declared" case and rethrows anything else, so a real error in a provider still surfaces. --- src/jolt/time/fmt.clj | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/jolt/time/fmt.clj b/src/jolt/time/fmt.clj index 6c9ab86..53ec30c 100644 --- a/src/jolt/time/fmt.clj +++ b/src/jolt/time/fmt.clj @@ -176,6 +176,30 @@ {:eq (fn [a b] (= (impl/field a :id) (impl/field b :id))) :hash (fn [l] (hash (impl/field l :id))) :str (fn [l] (impl/field l :id)) :cmp nil :classes #{"java.util.Locale" "Locale"}}) +;; jolt core's java.text.SimpleDateFormat renders MMM/MMMM/EEE/EEEE through a +;; :date-names extension point and carries ROOT alone, because core has no locale +;; data of its own. Hand it the bundled tables so an explicit locale renders in its +;; own language there too, not only through this library's DateTimeFormatter. +;; +;; Guarded on the point existing, so a jolt older than it still loads this library +;; and simply keeps ROOT names in SimpleDateFormat. The guard is deliberately +;; narrow: it swallows the one "not declared" case and rethrows anything else, so a +;; genuine error in a provider still surfaces. +(defn- register-date-names! [] + (when-let [reg (resolve 'jolt.host/register-extension!)] + (doseq [[id spec] ld/locales :when (not= id "")] + (reg :date-names id + {:months (:months spec) :months-short (:months-short spec) + :days (:days spec) :days-short (:days-short spec)})) + true)) + +(defonce ^:private date-names-registered + (try (boolean (register-date-names!)) + (catch Exception e + (if (re-find #"no extension point" (or (ex-message e) "")) + false + (throw e))))) + (def ^:private style-key {"SHORT" :short "MEDIUM" :medium "LONG" :long "FULL" :full}) (statics! ["FormatStyle" "java.time.format.FormatStyle"] (into {} (map (fn [s] [s (impl/value :jolt.time/format-style {:style s})]) ["SHORT" "MEDIUM" "LONG" "FULL"]))) From cdf679f5dadb701ea7fc4e7124db9d469d093432 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 31 Jul 2026 18:45:11 -0400 Subject: [PATCH 4/4] Feed core's number-symbols and currency-data points jolt core declares :number-symbols (String/format's separators) and :currency-data (NumberFormat/getCurrencyInstance) carrying ROOT alone, since it has no locale data. number-data holds both tables for 25 locale ids, measured from the reference JVM and verified by rebuilding the JVM's formatted output from the fields. Registration is now one helper across all three points, each skipped individually if this jolt does not declare it, so the library keeps loading against an older runtime. Note a language-only id has no country and so no currency: the JVM renders de as the generic sign and only de-DE carries the euro. Both are in the table, as measured. --- src/jolt/time/fmt.clj | 39 +++++++++++++----- src/jolt/time/number_data.clj | 76 +++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 src/jolt/time/number_data.clj diff --git a/src/jolt/time/fmt.clj b/src/jolt/time/fmt.clj index 53ec30c..b8530c8 100644 --- a/src/jolt/time/fmt.clj +++ b/src/jolt/time/fmt.clj @@ -7,6 +7,7 @@ [jolt.host :as host] [jolt.time.impl :as impl :refer [civil-from-days days-from-civil]] [jolt.time.locale-data :as ld] + [jolt.time.number-data :as nd] [jolt.time.util :as u] [jolt.time.local :as l] [jolt.time.year :as y] @@ -185,20 +186,36 @@ ;; and simply keeps ROOT names in SimpleDateFormat. The guard is deliberately ;; narrow: it swallows the one "not declared" case and rethrows anything else, so a ;; genuine error in a provider still surfaces. -(defn- register-date-names! [] +;; The same applies to the other two locale-sensitive surfaces core declares: +;; :number-symbols (String/format's decimal and grouping separators) and +;; :currency-data (NumberFormat/getCurrencyInstance). All three are fed from the +;; bundled tables here, because core carries ROOT alone for each. +;; +;; A point core does not declare is skipped rather than failing the load, so this +;; library keeps working against a jolt that has only some of them. +(defn- register-point! [reg point entries] + (try + (doseq [[id data] entries :when (not= id "")] + (reg point id data)) + true + (catch Exception e + (if (re-find #"no extension point" (or (ex-message e) "")) + false + (throw e))))) + +(defn- register-locale-points! [] (when-let [reg (resolve 'jolt.host/register-extension!)] - (doseq [[id spec] ld/locales :when (not= id "")] - (reg :date-names id - {:months (:months spec) :months-short (:months-short spec) - :days (:days spec) :days-short (:days-short spec)})) + (register-point! reg :date-names + (for [[id spec] ld/locales] + [id {:months (:months spec) :months-short (:months-short spec) + :days (:days spec) :days-short (:days-short spec)}])) + (register-point! reg :number-symbols nd/number-symbols) + (register-point! reg :currency-data nd/currency) true)) -(defonce ^:private date-names-registered - (try (boolean (register-date-names!)) - (catch Exception e - (if (re-find #"no extension point" (or (ex-message e) "")) - false - (throw e))))) +;; register-point! already tolerates an undeclared point per point, so this only +;; has to run once. +(defonce ^:private locale-points-registered (boolean (register-locale-points!))) (def ^:private style-key {"SHORT" :short "MEDIUM" :medium "LONG" :long "FULL" :full}) (statics! ["FormatStyle" "java.time.format.FormatStyle"] diff --git a/src/jolt/time/number_data.clj b/src/jolt/time/number_data.clj new file mode 100644 index 0000000..2ef2521 --- /dev/null +++ b/src/jolt/time/number_data.clj @@ -0,0 +1,76 @@ +(ns jolt.time.number-data + "Per-locale number and currency symbols, measured from the reference JVM. + + Feeds jolt core's :number-symbols and :currency-data extension points, which + core declares with ROOT values only because it carries no locale data of its + own. Ids are BCP-47-ish lang-COUNTRY; the empty id is ROOT. + + Note a language-only id has no country and therefore no currency: the JVM + renders de as the generic currency sign, and only a country id such as + de-DE carries a real symbol. Both are here, as measured -- every currency + entry was checked by rebuilding the JVM's formatted output from these fields." + (:require [jolt.time.locale-data :as ld])) + +(def number-symbols + {"" {:decimal-sep "." :grouping-sep ","} + "de" {:decimal-sep "," :grouping-sep "."} + "de-CH" {:decimal-sep "." :grouping-sep "'"} + "de-DE" {:decimal-sep "," :grouping-sep "."} + "en" {:decimal-sep "." :grouping-sep ","} + "en-GB" {:decimal-sep "." :grouping-sep ","} + "en-US" {:decimal-sep "." :grouping-sep ","} + "es" {:decimal-sep "," :grouping-sep "."} + "es-ES" {:decimal-sep "," :grouping-sep "."} + "fr" {:decimal-sep "," :grouping-sep " "} + "fr-FR" {:decimal-sep "," :grouping-sep " "} + "it" {:decimal-sep "," :grouping-sep "."} + "it-IT" {:decimal-sep "," :grouping-sep "."} + "ja" {:decimal-sep "." :grouping-sep ","} + "ja-JP" {:decimal-sep "." :grouping-sep ","} + "ko" {:decimal-sep "." :grouping-sep ","} + "ko-KR" {:decimal-sep "." :grouping-sep ","} + "nl" {:decimal-sep "," :grouping-sep "."} + "nl-NL" {:decimal-sep "," :grouping-sep "."} + "pt" {:decimal-sep "," :grouping-sep "."} + "pt-BR" {:decimal-sep "," :grouping-sep "."} + "ru" {:decimal-sep "," :grouping-sep " "} + "ru-RU" {:decimal-sep "," :grouping-sep " "} + "zh" {:decimal-sep "." :grouping-sep ","} + "zh-CN" {:decimal-sep "." :grouping-sep ","}}) + +(def currency + {"" {:symbol "¤", :symbol-sep " ", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "de" {:symbol "¤", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "de-CH" {:symbol "CHF", :symbol-sep " ", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep "'"} + "de-DE" {:symbol "€", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "en" {:symbol "¤", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "en-GB" {:symbol "£", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "en-US" {:symbol "$", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "es" {:symbol "¤", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "es-ES" {:symbol "€", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "fr" {:symbol "¤", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep " "} + "fr-FR" {:symbol "€", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep " "} + "it" {:symbol "¤", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "it-IT" {:symbol "€", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "ja" {:symbol "¤", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "ja-JP" {:symbol "¥", :symbol-sep "", :symbol-first? true, :frac-digits 0, :decimal-sep ".", :grouping-sep ","} + "ko" {:symbol "¤", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "ko-KR" {:symbol "₩", :symbol-sep "", :symbol-first? true, :frac-digits 0, :decimal-sep ".", :grouping-sep ","} + "nl" {:symbol "¤", :symbol-sep " ", :symbol-first? true, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "nl-NL" {:symbol "€", :symbol-sep " ", :symbol-first? true, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "pt" {:symbol "¤", :symbol-sep " ", :symbol-first? true, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "pt-BR" {:symbol "R$", :symbol-sep " ", :symbol-first? true, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "ru" {:symbol "¤", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep " "} + "ru-RU" {:symbol "₽", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep " "} + "zh" {:symbol "¤", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "zh-CN" {:symbol "¥", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","}}) + +(defn symbols-for + "Number symbols for a locale id, narrowed the same way the pattern tables are." + [id] + (get number-symbols (ld/resolve-id id) (get number-symbols ""))) + +(defn currency-for + "Currency data for a locale id, narrowed the same way." + [id] + (get currency (ld/resolve-id id) (get currency "")))