// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// Unicode LDML date patterns, the language ICU, `Intl`, Java, date-fns and dayjs
// all speak: a run of a pattern letter names a field and its length picks the
// width, and text inside single quotes is copied through. [`Moment::pattern`]
// writes a moment the way a pattern says; [`parse_pattern`] reads it back.
//
// The letters are the ones UTS #35 Part 4 §8 defines, in English. A locale with a
// zone database would name a zone; this package has none, so the offset-based
// letters fall back to the `GMT±HH:MM` spelling, which is what babel and ICU do
// for a zone they only know by its offset.
///|
/// One piece of a pattern: either literal text or a run of a single pattern letter.
priv struct PToken {
is_lit : Bool
lit : String
letter : Char
count : Int32
}
///|
/// A moment written by an LDML date pattern.
///
/// `pattern("yyyy-MM-dd")` is the calendar date, `pattern("EEEE, MMMM d, yyyy")`
/// is `Tuesday, September 22, 2026`, and text in single quotes is literal, so
/// `pattern("yyyy-MM-dd'T'HH:mm:ss")` writes an ISO-style timestamp.
pub fn Moment::pattern(self : Moment, pattern : StringView) -> String {
let (d, t, z, _) = decompose(self)
let off = match z {
Some(Utc) => 0
Some(Offset(m)) => m
None => 0
}
let toks = scan_pattern(pattern.to_owned())
let out = StringBuilder()
for i = 0; i < toks.length(); i = i + 1 {
write_ptoken(out, toks[i], d, t, off)
}
out.to_string()
}
///|
/// A moment read by an LDML date pattern.
///
/// The inverse of [`Moment::pattern`] for the field letters that carry a value:
/// years, months (numeric or named), days, hours, minutes, seconds, fractions and
/// zone offsets. A pattern that names an offset yields `Zoned`; one that does not
/// yields `Plain`, `Day` or `Clock`.
pub fn parse_pattern(
src : StringView,
pattern : StringView,
) -> Moment raise Refused {
let s = src.to_owned()
let toks = scan_pattern(pattern.to_owned())
let mut year = 1900
let mut month = 1
let mut mday = 1
let mut hour = 0
let mut minute = 0
let mut sec = 0
let mut nano = 0
let mut pm : Bool? = None
let mut off : Int32? = None
let mut saw_date = false
let mut saw_time = false
let mut j = 0
for i = 0; i < toks.length(); i = i + 1 {
let tok = toks[i]
if tok.is_lit {
j = plit(s, j, tok.lit)
continue
}
let letter = tok.letter
let count = tok.count
match letter {
'G' => j = skip_name(s, j)
'y' | 'u' => {
let n = if count == 2 { 2 } else { 4 }
let (v, k) = take(s, j, n)
year = if count == 2 && v < 69 {
2000 + v
} else if count == 2 {
1900 + v
} else {
v
}
j = k
saw_date = true
}
'Y' => {
let (v, k) = take(s, j, if count == 2 { 2 } else { 4 })
year = v
j = k
}
'M' | 'L' => {
if count <= 2 {
let (v, k) = take(s, j, 2)
month = v
j = k
} else {
let (m, k) = read_month(s, j)
month = m
j = k
}
saw_date = true
}
'Q' | 'q' => j = skip_digits_or_name(s, j)
'd' => {
let (v, k) = take(s, j, 2)
mday = v
j = k
saw_date = true
}
'D' => j = skip_digits_or_name(s, j)
'F' | 'w' | 'W' => j = skip_digits_or_name(s, j)
'E' | 'e' | 'c' =>
if count >= 3 {
j = skip_name(s, j)
} else {
j = skip_digits_or_name(s, j)
}
'a' => {
if j + 2 > s.length() {
raise Truncated("expected AM or PM")
}
let word = s[j:j + 2].to_owned().to_upper()
pm = match word {
"AM" => Some(false)
"PM" => Some(true)
_ => raise Malformed("expected AM or PM at \{j}")
}
j = j + 2
}
'h' | 'K' => {
let (v, k) = take(s, j, 2)
hour = v
j = k
saw_time = true
}
'H' | 'k' => {
let (v, k) = take(s, j, 2)
hour = if letter == 'k' && v == 24 { 0 } else { v }
j = k
saw_time = true
}
'm' => {
let (v, k) = take(s, j, 2)
minute = v
j = k
saw_time = true
}
's' => {
let (v, k) = take(s, j, 2)
sec = v
j = k
saw_time = true
}
'S' => {
let most = if count > 9 { 9 } else { count }
let start = j
let mut v = 0
while j < s.length() && is_digit(s[j].to_int()) && j - start < most {
v = v * 10 + (s[j].to_int() - 48)
j = j + 1
}
if j == start {
raise Malformed("expected a fraction at \{start}")
}
for k = j - start; k < 9; k = k + 1 {
v = v * 10
}
nano = v
saw_time = true
}
'A' => j = skip_digits_or_name(s, j)
'z' | 'Z' | 'O' | 'v' | 'V' | 'X' | 'x' => {
// Long forms prefix the offset with `GMT`; skip it before the sign.
if j + 3 <= s.length() && s[j:j + 3].to_owned() == "GMT" {
j = j + 3
}
let (o, k) = read_offset(s, j)
off = Some(o)
j = k
}
_ => ()
}
}
match pm {
Some(true) => if hour < 12 { hour = hour + 12 }
Some(false) => if hour == 12 { hour = 0 }
None => ()
}
let time = Time::new(hour, minute, second=sec, nano~)
if !saw_date {
return Clock(time~)
}
let date = Date::new(year, month, mday)
if !saw_time {
return Day(date~)
}
match off {
Some(0) => Zoned(date~, time~, zone=Utc)
Some(m) => Zoned(date~, time~, zone=Offset(m))
None => Plain(date~, time~)
}
}
///|
fn write_ptoken(
out : StringBuilder,
tok : PToken,
d : Date,
t : Time,
off : Int32,
) -> Unit {
if tok.is_lit {
out.write_string(tok.lit)
return
}
let count = tok.count
let (iso_year, _, _) = d.isocalendar()
let wd = d.weekday()
let q = (d.month - 1) / 3 + 1
match tok.letter {
'G' => out.write_string(if count >= 4 { "Anno Domini" } else { "AD" })
'y' => out.write_string(year_digits(d.year, count))
'Y' => out.write_string(year_digits(iso_year, count))
'u' => out.write_string(year_digits(d.year, count))
'Q' | 'q' =>
if count == 1 {
out.write_string(q.to_string())
} else if count == 2 {
out.write_string(pad(q, 2))
} else if count == 3 {
out.write_string("Q" + q.to_string())
} else {
out.write_string(ordinal(q) + " quarter")
}
'M' | 'L' =>
if count == 1 {
out.write_string(d.month.to_string())
} else if count == 2 {
out.write_string(pad(d.month, 2))
} else if count == 3 {
out.write_string(months[d.month - 1])
} else if count == 4 {
out.write_string(long_months[d.month - 1])
} else {
out.write_string(first_chars(long_months[d.month - 1], 1))
}
'w' => out.write_string(pad(week_mon(d) + 1, count))
'W' => out.write_string(pad(week_of_month(d), count))
'd' => out.write_string(pad(d.day, count))
'D' => out.write_string(pad(d.yearday(), count))
'F' => out.write_string(pad((d.day - 1) / 7 + 1, count))
'E' =>
if count <= 3 {
out.write_string(weekdays[wd])
} else if count == 4 {
out.write_string(long_days[wd])
} else if count == 5 {
out.write_string(first_chars(long_days[wd], 1))
} else {
out.write_string(first_chars(long_days[wd], 2))
}
'e' =>
if count == 1 {
out.write_string(d.isoweekday().to_string())
} else if count == 2 {
out.write_string(pad(d.isoweekday(), 2))
} else if count == 3 {
out.write_string(weekdays[wd])
} else if count == 4 {
out.write_string(long_days[wd])
} else if count == 5 {
out.write_string(first_chars(long_days[wd], 1))
} else {
out.write_string(first_chars(long_days[wd], 2))
}
'a' => out.write_string(if t.hour < 12 { "AM" } else { "PM" })
'h' => out.write_string(pad(hour12(t), count))
'H' => out.write_string(pad(t.hour, count))
'K' => out.write_string(pad(t.hour % 12, count))
'k' => out.write_string(pad(if t.hour == 0 { 24 } else { t.hour }, count))
'm' => out.write_string(pad(t.minute, count))
's' => out.write_string(pad(t.second, count))
'S' => out.write_string(frac_digits(t.nano, count))
'A' =>
out.write_string(
pad(
(t.hour * 3600 + t.minute * 60 + t.second) * 1000 + t.nano / 1_000_000,
count,
),
)
'z' => out.write_string(zone_z(off, count))
'Z' => out.write_string(zone_Z(off, count))
'O' => out.write_string(zone_O(off, count))
'v' | 'V' => out.write_string(zone_z(off, count))
'X' => out.write_string(zone_X(off, count, false))
'x' => out.write_string(zone_X(off, count, true))
other =>
for fi = 0; fi < count; fi = fi + 1 {
out.write_char(other)
}
}
}
///|
/// A year laid out by an LDML `y`/`Y`/`u` pattern: two digits when the count is
/// two, otherwise at least `count` digits.
fn year_digits(year : Int32, count : Int32) -> String {
if count == 2 {
return pad((year % 100 + 100) % 100, 2)
}
let width = if count < 1 { 1 } else { count }
if year < 0 {
"-" + pad(-year, width)
} else {
pad(year, width)
}
}
///|
fn week_of_month(d : Date) -> Int32 {
let first = { year: d.year, month: d.month, day: 1, }
let offset = (first.weekday() + 6) % 7
(d.day - 1 + offset) / 7 + 1
}
///|
/// The first `n` characters of a name, for the narrow and short widths.
fn first_chars(s : String, n : Int32) -> String {
let mut k = if n > s.length() { s.length() } else { n }
if k < 0 {
k = 0
}
s[0:k].to_owned()
}
///|
/// The fractional second as `count` digits, truncated from the nine-digit
/// nanoseconds, right-padded with zeros past nine.
fn frac_digits(nano : Int32, count : Int32) -> String {
let full = pad(nano, 9)
if count <= 9 {
return full[0:count].to_owned()
}
let out = StringBuilder()
out.write_string(full)
for fi = 9; fi < count; fi = fi + 1 {
out.write_char('0')
}
out.to_string()
}
///|
fn ordinal(n : Int32) -> String {
let suffix = if n % 100 == 11 || n % 100 == 12 || n % 100 == 13 {
"th"
} else {
match n % 10 {
1 => "st"
2 => "nd"
3 => "rd"
_ => "th"
}
}
n.to_string() + suffix
}
///|
/// `z`: a short zone name, or the offset; the long form spells `GMT±HH:MM` out.
fn zone_z(off : Int32, count : Int32) -> String {
if count >= 4 {
if off == 0 {
"Coordinated Universal Time"
} else {
"GMT" + offset_colon(off)
}
} else if off == 0 {
"UTC"
} else {
offset_plain(off, false)
}
}
///|
/// `Z`: `±HHMM`, `GMT±HH:MM`, or `Z`/`±HH:MM` for the five-letter form.
fn zone_Z(off : Int32, count : Int32) -> String {
if count >= 5 {
if off == 0 {
"Z"
} else {
offset_colon(off)
}
} else if count == 4 {
"GMT" + offset_colon(off)
} else {
offset_plain(off, false)
}
}
///|
/// `O`: `GMT±H` or `GMT±HH:MM`.
fn zone_O(off : Int32, count : Int32) -> String {
if count >= 4 {
"GMT" + offset_colon(off)
} else {
let sign = if off < 0 { "-" } else { "+" }
let a = if off < 0 { -off } else { off }
"GMT" + sign + (a / 60).to_string()
}
}
///|
/// `X` and `x`: the ISO 8601 offset. The upper-case `X` writes `Z` at UTC; the
/// lower-case `x` writes the zero offset out. `minus` selects the lower form.
fn zone_X(off : Int32, count : Int32, minus : Bool) -> String {
let a = if off < 0 { -off } else { off }
let hh = a / 60
let mm = a % 60
if off == 0 && !minus {
return "Z"
}
let sign = if off < 0 { "-" } else { "+" }
if count == 1 {
if mm == 0 {
sign + pad(hh, 2)
} else {
sign + pad(hh, 2) + pad(mm, 2)
}
} else if count == 2 || count == 4 {
sign + pad(hh, 2) + pad(mm, 2)
} else {
sign + pad(hh, 2) + ":" + pad(mm, 2)
}
}
///|
/// `±HHMM`.
fn offset_plain(off : Int32, _with_seconds : Bool) -> String {
let sign = if off < 0 { "-" } else { "+" }
let a = if off < 0 { -off } else { off }
sign + pad(a / 60, 2) + pad(a % 60, 2)
}
///|
/// `±HH:MM`.
fn offset_colon(off : Int32) -> String {
let sign = if off < 0 { "-" } else { "+" }
let a = if off < 0 { -off } else { off }
sign + pad(a / 60, 2) + ":" + pad(a % 60, 2)
}
///|
/// A pattern split into literal runs and single-letter runs, with quoting applied.
fn scan_pattern(pattern : String) -> Array[PToken] {
let toks = []
let mut i = 0
while i < pattern.length() {
let c = pattern[i].to_int()
if c == 0x27 {
// A quoted run: `''` is one quote, anything else is literal.
let run = StringBuilder()
i = i + 1
while i < pattern.length() && pattern[i].to_int() != 0x27 {
run.write_char(pattern[i].unsafe_to_char())
i = i + 1
}
if i < pattern.length() {
i = i + 1
}
toks.push({ is_lit: true, lit: run.to_string(), letter: ' ', count: 0, })
} else if is_letter(c) {
let letter = pattern[i].unsafe_to_char()
let mut k = i + 1
while k < pattern.length() && pattern[k].to_int() == c {
k = k + 1
}
toks.push({ is_lit: false, lit: "", letter, count: k - i, })
i = k
} else {
let run = StringBuilder()
while i < pattern.length() &&
!is_letter(pattern[i].to_int()) &&
pattern[i].to_int() != 0x27 {
run.write_char(pattern[i].unsafe_to_char())
i = i + 1
}
toks.push({ is_lit: true, lit: run.to_string(), letter: ' ', count: 0, })
}
}
toks
}
///|
fn is_letter(c : Int32) -> Bool {
(c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a)
}
///|
/// A literal run matched, with a space swallowing a run of any whitespace.
fn plit(s : String, j : Int32, text : String) -> Int32 raise Refused {
let mut k = j
for c = 0; c < text.length(); c = c + 1 {
let ch = text[c].to_int()
if ch == 0x20 {
while k < s.length() && s[k].to_int() == 0x20 {
k = k + 1
}
} else {
if k >= s.length() || s[k].to_int() != ch {
raise Malformed("text does not match the pattern at \{k}")
}
k = k + 1
}
}
k
}
///|
/// Past either digits or a name, for a field this reader does not keep.
fn skip_digits_or_name(s : String, j : Int32) -> Int32 {
let mut k = j
while k < s.length() && is_digit(s[k].to_int()) {
k = k + 1
}
if k > j {
return k
}
skip_name(s, j)
}