Skip to main content

nodejs/
datefmt.rs

1//! `Date.prototype.toLocaleString` / `toLocaleDateString` / `toLocaleTimeString`
2//! with their `options` argument, in the `en-US` shape.
3//!
4//! Node formats these through ICU: ECMA-402's `CreateDateTimeFormat` turns the
5//! options bag into a SKELETON (`"yMMMMEEEEd"`, `"hm"`, …), and ICU's
6//! `DateTimePatternGenerator` turns the skeleton into a PATTERN
7//! (`"EEEE, MMMM d, y"`) by a distance search over the locale's
8//! `availableFormats`, adjusting field widths, appending fields no pattern
9//! carries and gluing a date half to a time half. That is what this module is: a
10//! port of `dtptngen.cpp`'s `getBestPattern` / `getBestRaw` /
11//! `getBestAppending` / `adjustFieldTypes` over the en data ICU 77 ships, so
12//! every option combination resolves to the pattern node's does — including the
13//! odd ones, like `{day: '2-digit', hour: '2-digit', second: 'numeric'}` printing
14//! `09, 9 (hour: 13)`.
15//!
16//! The `locales` argument is still ignored: every locale formats as en-US, as
17//! `numfmt` does for numbers (see BUGS.md).
18
19use crate::host::{self, with_host};
20use fusevm::Value;
21
22// ICU's `UDateTimePatternField` indices.
23const ERA: usize = 0;
24const YEAR: usize = 1;
25const QUARTER: usize = 2;
26const MONTH: usize = 3;
27const WEEK_OF_YEAR: usize = 4;
28const WEEK_OF_MONTH: usize = 5;
29const WEEKDAY: usize = 6;
30const DAY_OF_YEAR: usize = 7;
31const DAY_OF_WEEK_IN_MONTH: usize = 8;
32const DAY: usize = 9;
33const DAYPERIOD: usize = 10;
34const HOUR: usize = 11;
35const MINUTE: usize = 12;
36const SECOND: usize = 13;
37const FRACTIONAL: usize = 14;
38const ZONE: usize = 15;
39const FIELD_COUNT: usize = 16;
40
41const DATE_MASK: u32 = (1 << DAYPERIOD) - 1;
42const TIME_MASK: u32 = ((1 << FIELD_COUNT) - 1) & !DATE_MASK;
43const SECOND_AND_FRACTIONAL: u32 = (1 << SECOND) | (1 << FRACTIONAL);
44
45// ICU's field "types": numeric types are positive (and the field LENGTH is added
46// to them in a skeleton), text widths negative; the distance between two
47// skeletons is the sum of the type differences.
48const NUMERIC: i32 = 0x100;
49const DELTA: i32 = 0x10;
50const NARROW: i32 = -0x101;
51const SHORTER: i32 = -0x102;
52const SHORT: i32 = -0x103;
53const LONG: i32 = -0x104;
54const EXTRA_FIELD: i32 = 0x10000;
55const MISSING_FIELD: i32 = 0x1000;
56
57/// ICU's `dtTypes` table, in its order: `(pattern char, field, type, min length)`.
58/// A run of `c` repeated `n` times is the LAST row for `c` whose min length is
59/// at most `n`.
60const TYPES: &[(char, usize, i32, usize)] = &[
61    ('G', ERA, SHORT, 1),
62    ('G', ERA, LONG, 4),
63    ('G', ERA, NARROW, 5),
64    ('y', YEAR, NUMERIC, 1),
65    ('Q', QUARTER, NUMERIC, 1),
66    ('Q', QUARTER, SHORT, 3),
67    ('Q', QUARTER, LONG, 4),
68    ('Q', QUARTER, NARROW, 5),
69    ('M', MONTH, NUMERIC, 1),
70    ('M', MONTH, SHORT, 3),
71    ('M', MONTH, LONG, 4),
72    ('M', MONTH, NARROW, 5),
73    ('L', MONTH, NUMERIC + DELTA, 1),
74    ('L', MONTH, SHORT - DELTA, 3),
75    ('L', MONTH, LONG - DELTA, 4),
76    ('L', MONTH, NARROW - DELTA, 5),
77    ('w', WEEK_OF_YEAR, NUMERIC, 1),
78    ('W', WEEK_OF_MONTH, NUMERIC, 1),
79    ('E', WEEKDAY, SHORT, 1),
80    ('E', WEEKDAY, LONG, 4),
81    ('E', WEEKDAY, NARROW, 5),
82    ('E', WEEKDAY, SHORTER, 6),
83    ('c', WEEKDAY, NUMERIC + 2 * DELTA, 1),
84    ('c', WEEKDAY, SHORT - 2 * DELTA, 3),
85    ('c', WEEKDAY, LONG - 2 * DELTA, 4),
86    ('c', WEEKDAY, NARROW - 2 * DELTA, 5),
87    ('c', WEEKDAY, SHORTER - 2 * DELTA, 6),
88    ('d', DAY, NUMERIC, 1),
89    ('D', DAY_OF_YEAR, NUMERIC, 1),
90    ('F', DAY_OF_WEEK_IN_MONTH, NUMERIC, 1),
91    ('a', DAYPERIOD, SHORT, 1),
92    ('a', DAYPERIOD, LONG, 4),
93    ('a', DAYPERIOD, NARROW, 5),
94    ('B', DAYPERIOD, SHORT - 3 * DELTA, 1),
95    ('B', DAYPERIOD, LONG - 3 * DELTA, 4),
96    ('B', DAYPERIOD, NARROW - 3 * DELTA, 5),
97    ('H', HOUR, NUMERIC + 10 * DELTA, 1),
98    ('k', HOUR, NUMERIC + 11 * DELTA, 1),
99    ('h', HOUR, NUMERIC, 1),
100    ('K', HOUR, NUMERIC + DELTA, 1),
101    ('m', MINUTE, NUMERIC, 1),
102    ('s', SECOND, NUMERIC, 1),
103    ('S', FRACTIONAL, NUMERIC, 1),
104    ('v', ZONE, SHORT - 2 * DELTA, 1),
105    ('v', ZONE, LONG - 2 * DELTA, 4),
106    ('z', ZONE, SHORT, 1),
107    ('z', ZONE, LONG, 4),
108    ('O', ZONE, SHORT - 3 * DELTA, 1),
109    ('O', ZONE, LONG - 3 * DELTA, 4),
110];
111
112/// The `dtTypes` row a run of `c` × `len` belongs to (`getCanonicalIndex`).
113fn type_row(c: char, len: usize) -> Option<(usize, i32, usize)> {
114    TYPES
115        .iter()
116        .rev()
117        .find(|r| r.0 == c && r.3 <= len)
118        .or_else(|| TYPES.iter().find(|r| r.0 == c))
119        .map(|r| (r.1, r.2, r.3))
120}
121
122/// One token of a pattern: a field (a run of one letter), or literal text kept
123/// verbatim — a quoted run with its quotes, or punctuation and spaces.
124#[derive(Clone, Debug, PartialEq)]
125enum Tok {
126    Field(char, usize),
127    Lit(String),
128}
129
130fn tokenize(pattern: &str) -> Vec<Tok> {
131    let cs: Vec<char> = pattern.chars().collect();
132    let mut out = Vec::new();
133    let mut i = 0;
134    while i < cs.len() {
135        let c = cs[i];
136        if c.is_ascii_alphabetic() {
137            let start = i;
138            while i < cs.len() && cs[i] == c {
139                i += 1;
140            }
141            out.push(Tok::Field(c, i - start));
142        } else if c == '\'' {
143            // A quoted literal runs to the next lone quote; `''` is a quote.
144            let start = i;
145            i += 1;
146            while i < cs.len() {
147                if cs[i] == '\'' {
148                    if cs.get(i + 1) == Some(&'\'') {
149                        i += 2;
150                        continue;
151                    }
152                    i += 1;
153                    break;
154                }
155                i += 1;
156            }
157            out.push(Tok::Lit(cs[start..i].iter().collect()));
158        } else {
159            out.push(Tok::Lit(c.to_string()));
160            i += 1;
161        }
162    }
163    out
164}
165
166/// A skeleton (ICU's `PtnSkeleton`): per field, the run that names it, its
167/// type, and the length of its BASE form (the row's minimum length).
168#[derive(Clone, Debug, PartialEq, Default)]
169struct Skeleton {
170    orig: [Option<(char, usize)>; FIELD_COUNT],
171    ty: [i32; FIELD_COUNT],
172    base: [Option<(char, usize)>; FIELD_COUNT],
173    /// The `a` ICU adds to a 12-hour skeleton that names no day period.
174    added_day_period: bool,
175}
176
177impl Skeleton {
178    /// `DateTimeMatcher::set`: the skeleton a pattern (or skeleton string)
179    /// denotes. Literals are skipped.
180    fn of(pattern: &str) -> Skeleton {
181        let mut s = Skeleton::default();
182        for t in tokenize(pattern) {
183            let Tok::Field(c, len) = t else { continue };
184            let Some((field, ty, min)) = type_row(c, len) else {
185                continue;
186            };
187            s.orig[field] = Some((c, len));
188            let base_char = TYPES
189                .iter()
190                .find(|r| r.1 == field && r.2 == ty)
191                .map_or(c, |r| r.0);
192            s.base[field] = Some((base_char, min));
193            s.ty[field] = if ty > 0 { ty + len as i32 } else { ty };
194        }
195        // ICU #20739: minutes with fractional seconds but no seconds force a
196        // seconds field, so the fraction has something to follow.
197        if s.orig[MINUTE].is_some() && s.orig[FRACTIONAL].is_some() && s.orig[SECOND].is_none() {
198            s.orig[SECOND] = Some(('s', 1));
199            s.base[SECOND] = Some(('s', 1));
200            s.ty[SECOND] = NUMERIC + 1;
201        }
202        // A 12-hour skeleton without a day period gets ICU's default `a`; a
203        // 24-hour one drops any day period it names.
204        if let Some((h, _)) = s.orig[HOUR] {
205            if h == 'h' || h == 'K' {
206                if s.orig[DAYPERIOD].is_none() {
207                    s.orig[DAYPERIOD] = Some(('a', 1));
208                    s.base[DAYPERIOD] = Some(('a', 1));
209                    s.ty[DAYPERIOD] = SHORT;
210                    s.added_day_period = true;
211                }
212            } else if s.orig[DAYPERIOD].is_some() {
213                s.orig[DAYPERIOD] = None;
214                s.base[DAYPERIOD] = None;
215                s.ty[DAYPERIOD] = 0;
216            }
217        }
218        s
219    }
220
221    fn mask(&self) -> u32 {
222        (0..FIELD_COUNT)
223            .filter(|&i| self.ty[i] != 0)
224            .fold(0, |m, i| m | (1 << i))
225    }
226
227    /// The base pattern's text (`getBasePattern`), which keys ICU's buckets.
228    fn base_string(&self) -> String {
229        self.base
230            .iter()
231            .flatten()
232            .map(|&(c, n)| c.to_string().repeat(n))
233            .collect()
234    }
235
236    /// `DateTimeMatcher::getDistance`, restricted to the fields in `include`.
237    fn distance(&self, other: &Skeleton, include: u32) -> (i32, u32, u32) {
238        let (mut d, mut missing, mut extra) = (0, 0u32, 0u32);
239        for i in 0..FIELD_COUNT {
240            let mine = if include & (1 << i) == 0 {
241                0
242            } else {
243                self.ty[i]
244            };
245            let theirs = other.ty[i];
246            if mine == theirs {
247                continue;
248            }
249            if mine == 0 {
250                d += EXTRA_FIELD;
251                extra |= 1 << i;
252            } else if theirs == 0 {
253                d += MISSING_FIELD;
254                missing |= 1 << i;
255            } else {
256                d += (mine - theirs).abs();
257            }
258        }
259        (d, missing, extra)
260    }
261}
262
263/// One generator entry: the skeleton it is matched by, its pattern, and
264/// whether the skeleton was given explicitly (an `availableFormats` key) or
265/// derived from the pattern (a canonical item or a style pattern).
266struct Entry {
267    skel: Skeleton,
268    pattern: &'static str,
269    specified: bool,
270}
271
272/// en's `availableFormats` (ICU 77 / CLDR 47), in the resource bundle's key
273/// order. The week-numbering items are left out: ECMA-402 never asks for a
274/// week field, and they carry fields no request has, so they never win.
275const AVAILABLE_FORMATS: &[(&str, &str)] = &[
276    ("Bh", "h B"),
277    ("Bhm", "h:mm B"),
278    ("Bhms", "h:mm:ss B"),
279    ("E", "ccc"),
280    ("EBh", "E h B"),
281    ("EBhm", "E h:mm B"),
282    ("EBhms", "E h:mm:ss B"),
283    ("EHm", "E HH:mm"),
284    ("EHms", "E HH:mm:ss"),
285    ("Ed", "d E"),
286    ("Eh", "E h a"),
287    ("Ehm", "E h:mm a"),
288    ("Ehms", "E h:mm:ss a"),
289    ("Gy", "y G"),
290    ("GyM", "M/y G"),
291    ("GyMEd", "E, M/d/y G"),
292    ("GyMMM", "MMM y G"),
293    ("GyMMMEd", "E, MMM d, y G"),
294    ("GyMMMd", "MMM d, y G"),
295    ("GyMd", "M/d/y G"),
296    ("H", "HH"),
297    ("Hm", "HH:mm"),
298    ("Hms", "HH:mm:ss"),
299    ("Hmsv", "HH:mm:ss v"),
300    ("Hmv", "HH:mm v"),
301    ("Hv", "HH v"),
302    ("M", "L"),
303    ("MEd", "E, M/d"),
304    ("MMM", "LLL"),
305    ("MMMEd", "E, MMM d"),
306    ("MMMMd", "MMMM d"),
307    ("MMMd", "MMM d"),
308    ("Md", "M/d"),
309    ("d", "d"),
310    ("h", "h a"),
311    ("hm", "h:mm a"),
312    ("hms", "h:mm:ss a"),
313    ("hmsv", "h:mm:ss a v"),
314    ("hmv", "h:mm a v"),
315    ("hv", "h a v"),
316    ("ms", "mm:ss"),
317    ("y", "y"),
318    ("yM", "M/y"),
319    ("yMEd", "E, M/d/y"),
320    ("yMMM", "MMM y"),
321    ("yMMMEd", "E, MMM d, y"),
322    ("yMMMM", "MMMM y"),
323    ("yMMMd", "MMM d, y"),
324    ("yMd", "M/d/y"),
325    ("yQQQ", "QQQ y"),
326    ("yQQQQ", "QQQQ y"),
327];
328
329/// en's date styles (`full` … `short`) and time styles. ICU's own patterns put
330/// U+202F before the day period; V8 swaps it for a space, so these do too.
331const DATE_STYLES: [&str; 4] = ["EEEE, MMMM d, y", "MMMM d, y", "MMM d, y", "M/d/yy"];
332const TIME_STYLES: [&str; 4] = ["h:mm:ss a zzzz", "h:mm:ss a z", "h:mm:ss a", "h:mm a"];
333
334/// en's `dateTimeFormats` (the `atTime` set, which is what the generator and
335/// the styles use), indexed like the styles.
336const DATE_TIME_GLUE: [&str; 4] = ["{1} 'at' {0}", "{1} 'at' {0}", "{1}, {0}", "{1}, {0}"];
337
338/// The generator's entries in ICU's ITERATION order, built as `initData` does:
339/// canonical one-field items, then the style patterns, then the `mm:ss` hack,
340/// all without override; then `availableFormats`, overriding. Iteration walks
341/// ICU's buckets — keyed by the base pattern's first letter, `A`–`Z` then
342/// `a`–`z` — each in insertion order, and that order breaks distance ties.
343fn entries() -> &'static [Entry] {
344    static ENTRIES: std::sync::OnceLock<Vec<Entry>> = std::sync::OnceLock::new();
345    ENTRIES.get_or_init(|| {
346        let mut list: Vec<Entry> = Vec::new();
347        // `addPatternWithSkeleton` with `override = false` and no skeleton: a
348        // BASE conflict or an identical skeleton keeps the earlier entry.
349        let add_derived = |list: &mut Vec<Entry>, pattern: &'static str| {
350            let skel = Skeleton::of(pattern);
351            let base = skel.base_string();
352            if list.iter().any(|e| e.skel.base_string() == base) {
353                return;
354            }
355            list.push(Entry {
356                skel,
357                pattern,
358                specified: false,
359            });
360        };
361        const CANONICAL: &[&str] = &[
362            "G", "y", "Q", "M", "w", "W", "E", "D", "F", "d", "a", "H", "m", "s", "S", "v",
363        ];
364        for p in CANONICAL {
365            add_derived(&mut list, p);
366        }
367        for i in 0..4 {
368            add_derived(&mut list, DATE_STYLES[i]);
369            add_derived(&mut list, TIME_STYLES[i]);
370        }
371        // `hackTimes` on the medium time pattern.
372        add_derived(&mut list, "mm:ss");
373        for &(key, pattern) in AVAILABLE_FORMATS {
374            let skel = Skeleton::of(key);
375            match list.iter_mut().find(|e| e.skel == skel) {
376                Some(e) => {
377                    e.pattern = pattern;
378                    e.specified = true;
379                }
380                None => list.push(Entry {
381                    skel,
382                    pattern,
383                    specified: true,
384                }),
385            }
386        }
387        let bucket = |e: &Entry| {
388            let c = e.skel.base_string().chars().next().unwrap_or('a');
389            if c.is_ascii_uppercase() {
390                c as u32 - 'A' as u32
391            } else {
392                26 + c as u32 - 'a' as u32
393            }
394        };
395        list.sort_by_key(bucket);
396        list
397    })
398}
399
400/// `getBestRaw`: the entry nearest `req` over the fields in `include`, with
401/// the missing and extra field masks of that match.
402fn best_raw(req: &Skeleton, include: u32) -> (&'static Entry, u32, u32) {
403    let mut best: Option<(&Entry, i32, u32, u32)> = None;
404    for e in entries() {
405        let (d, missing, extra) = req.distance(&e.skel, include);
406        let better = match best {
407            None => true,
408            Some((_, bd, bmissing, _)) => d < bd || (d == bd && bmissing < missing),
409        };
410        if better {
411            best = Some((e, d, missing, extra));
412            if d == 0 {
413                break;
414            }
415        }
416    }
417    let (e, _, missing, extra) = best.expect("the generator has entries");
418    (e, missing, extra)
419}
420
421/// `adjustFieldTypes` with `UDATPG_MATCH_HOUR_FIELD_LENGTH` (what V8 passes):
422/// each field the request names takes the request's width, except where ICU
423/// keeps the pattern's own.
424fn adjust(
425    pattern: &str,
426    entry_skel: Option<&Skeleton>,
427    req: &Skeleton,
428    fix_fraction: bool,
429) -> String {
430    let mut out = String::new();
431    for t in tokenize(pattern) {
432        let (c, len) = match t {
433            Tok::Lit(s) => {
434                out.push_str(&s);
435                continue;
436            }
437            Tok::Field(c, len) => (c, len),
438        };
439        let Some((field, row_ty, _)) = type_row(c, len) else {
440            out.push_str(&c.to_string().repeat(len));
441            continue;
442        };
443        if fix_fraction && field == SECOND {
444            out.push_str(&c.to_string().repeat(len));
445            out.push('.');
446            if let Some((fc, flen)) = req.orig[FRACTIONAL] {
447                out.push_str(&fc.to_string().repeat(flen));
448            }
449            continue;
450        }
451        if req.ty[field] == 0 {
452            out.push_str(&c.to_string().repeat(len));
453            continue;
454        }
455        let (req_char, mut req_len) = req.orig[field].unwrap_or((c, len));
456        if req_char == 'E' && req_len < 3 {
457            req_len = 3;
458        }
459        let mut adj_len = req_len;
460        if field == MINUTE || field == SECOND {
461            adj_len = len;
462        } else if let Some(spec) = entry_skel {
463            let skel_len = spec.orig[field].map_or(0, |(_, n)| n);
464            let pat_numeric = row_ty > 0;
465            let skel_numeric = spec.ty[field] > 0;
466            if skel_len == req_len || pat_numeric != skel_numeric {
467                adj_len = len;
468            }
469        }
470        let keep_pattern_char = matches!(field, HOUR | MONTH | WEEKDAY | YEAR);
471        let mut ch = if keep_pattern_char { c } else { req_char };
472        if field == HOUR {
473            // en's default hour character is `h`.
474            ch = match req_char {
475                'h' => 'h',
476                'K' => 'h',
477                _ => ch,
478            };
479        }
480        out.push_str(&ch.to_string().repeat(adj_len));
481    }
482    out
483}
484
485/// `getBestAppending`: the pattern for the fields in `fields`, appending (with
486/// en's `appendItems`) whatever the nearest pattern lacks.
487fn best_appending(req: &Skeleton, fields: u32) -> String {
488    if fields == 0 {
489        return String::new();
490    }
491    let (e, mut missing, extra) = best_raw(req, fields);
492    let spec = |e: &Entry| e.specified.then_some(&e.skel).cloned();
493    let mut spec_skel = spec(e);
494    let mut result = adjust(e.pattern, spec_skel.as_ref(), req, false);
495    if missing == 0 && extra == 0 {
496        return result;
497    }
498    let mut last_missing = 0;
499    while missing != 0 {
500        if last_missing == missing {
501            break;
502        }
503        if missing & SECOND_AND_FRACTIONAL == 1 << FRACTIONAL
504            && fields & SECOND_AND_FRACTIONAL == SECOND_AND_FRACTIONAL
505        {
506            result = adjust(&result, spec_skel.as_ref(), req, true);
507            missing &= !(1 << FRACTIONAL);
508            continue;
509        }
510        let starting = missing;
511        let (e, m, _) = best_raw(req, missing);
512        missing = m;
513        spec_skel = spec(e);
514        let temp = adjust(e.pattern, spec_skel.as_ref(), req, false);
515        let found = starting & !missing;
516        let top = 31 - found.leading_zeros() as usize;
517        result = append_item(top, &result, &temp);
518        last_missing = missing;
519    }
520    result
521}
522
523/// en's `appendItems` (from root), with each field's wide display name.
524fn append_item(field: usize, result: &str, temp: &str) -> String {
525    let name = match field {
526        ERA => "era",
527        YEAR => "year",
528        QUARTER => "quarter",
529        MONTH => "month",
530        WEEK_OF_YEAR => "week",
531        WEEK_OF_MONTH => "week of month",
532        WEEKDAY => "day of the week",
533        DAY_OF_YEAR => "day of year",
534        DAY_OF_WEEK_IN_MONTH => "weekday of the month",
535        DAY => "day",
536        DAYPERIOD => "AM/PM",
537        HOUR => "hour",
538        MINUTE => "minute",
539        SECOND => "second",
540        FRACTIONAL => "F14",
541        _ => "time zone",
542    };
543    match field {
544        ERA | YEAR | WEEKDAY | ZONE => format!("{result} {temp}"),
545        QUARTER | MONTH | WEEK_OF_YEAR | DAY | HOUR | MINUTE | SECOND => {
546            format!("{result} ('{name}': {temp})")
547        }
548        _ => format!("{result} \u{251C}'{name}': {temp}\u{2524}"),
549    }
550}
551
552/// `getBestPattern(skeleton, UDATPG_MATCH_HOUR_FIELD_LENGTH)`.
553fn best_pattern(skeleton: &str) -> String {
554    let req = Skeleton::of(skeleton);
555    let (e, missing, extra) = best_raw(&req, u32::MAX);
556    if missing == 0 && extra == 0 {
557        return adjust(e.pattern, e.specified.then_some(&e.skel), &req, false);
558    }
559    let needed = req.mask();
560    let date = best_appending(&req, needed & DATE_MASK);
561    let time = best_appending(&req, needed & TIME_MASK);
562    if date.is_empty() {
563        return time;
564    }
565    if time.is_empty() {
566        return date;
567    }
568    let month_len = req.base[MONTH].map_or(0, |(_, n)| n);
569    let style = match month_len {
570        4 if req.base[WEEKDAY].is_some() => 0,
571        4 => 1,
572        3 => 2,
573        _ => 3,
574    };
575    glue(style, &date, &time)
576}
577
578fn glue(style: usize, date: &str, time: &str) -> String {
579    DATE_TIME_GLUE[style]
580        .replace("{1}", date)
581        .replace("{0}", time)
582}
583
584// ── options ──────────────────────────────────────────────────────────────────
585
586/// Which `toLocale*String` is asking: what it requires and defaults to
587/// (ECMA-402 `ToDateTimeOptions`).
588#[derive(Clone, Copy, PartialEq)]
589pub enum Required {
590    Any,
591    Date,
592    Time,
593}
594
595/// The hour cycle (`hourCycle` / `hour12`).
596#[derive(Clone, Copy, PartialEq)]
597enum Hc {
598    H11,
599    H12,
600    H23,
601    H24,
602}
603
604/// The zone a format renders in.
605#[derive(Clone)]
606enum Zone {
607    /// The process's zone (`TZ`).
608    Local,
609    Utc,
610    /// A fixed offset east of UTC, in minutes (`"+05:30"`, `Etc/GMT-5`).
611    Fixed(i64),
612    /// A named IANA zone from the system zoneinfo.
613    Named(std::rc::Rc<crate::tzif::Tz>),
614}
615
616fn out_of_range(method: &str, value: &str, name: &str) -> String {
617    host::range_error(&format!(
618        "Value {value} out of range for {method} options property {name}"
619    ))
620}
621
622fn string_option(
623    opts: &Value,
624    method: &str,
625    name: &str,
626    allowed: &[&str],
627) -> Result<Option<String>, String> {
628    let v = crate::builtins::get_property(opts, name)?;
629    if matches!(v, Value::Undef) {
630        return Ok(None);
631    }
632    let s = host::to_string_value(&v)?;
633    let s = with_host(|h| h.str_of(&s));
634    if !allowed.is_empty() && !allowed.contains(&s.as_str()) {
635        return Err(out_of_range(method, &s, name));
636    }
637    Ok(Some(s))
638}
639
640/// A resolved format: the ICU pattern and the zone it renders in.
641pub struct Format {
642    pattern: String,
643    zone: Zone,
644}
645
646const TEXT_WIDTHS: &[&str] = &["narrow", "short", "long"];
647const NUMERIC_WIDTHS: &[&str] = &["numeric", "2-digit"];
648
649/// `CreateDateTimeFormat` for the `toLocale*String` family: read and validate
650/// the options bag in the order V8 does, apply `ToDateTimeOptions`, and resolve
651/// the pattern.
652pub fn resolve(opts: &Value, required: Required, method: &str) -> Result<Format, String> {
653    if with_host(|h| h.is_null(opts)) {
654        return Err(host::type_error(&format!(
655            "{method} called on null or undefined"
656        )));
657    }
658    let is_object = matches!(opts, Value::Obj(_)) && !with_host(|h| host::is_primitive(h, opts));
659    let undef = Value::Undef;
660    let o = if is_object { opts } else { &undef };
661    let get = |name: &str, allowed: &[&str]| -> Result<Option<String>, String> {
662        if is_object {
663            string_option(o, method, name, allowed)
664        } else {
665            Ok(None)
666        }
667    };
668    let hour12 = if is_object {
669        let v = crate::builtins::get_property(o, "hour12")?;
670        (!matches!(v, Value::Undef)).then(|| with_host(|h| h.truthy(&v)))
671    } else {
672        None
673    };
674    let hour_cycle = get("hourCycle", &["h11", "h12", "h23", "h24"])?;
675    let time_zone = if is_object {
676        let v = crate::builtins::get_property(o, "timeZone")?;
677        if matches!(v, Value::Undef) {
678            None
679        } else {
680            let s = host::to_string_value(&v)?;
681            Some(with_host(|h| h.str_of(&s)))
682        }
683    } else {
684        None
685    };
686    let weekday = get("weekday", TEXT_WIDTHS)?;
687    let era = get("era", TEXT_WIDTHS)?;
688    let mut year = get("year", NUMERIC_WIDTHS)?;
689    let mut month = get("month", &["numeric", "2-digit", "narrow", "short", "long"])?;
690    let mut day = get("day", NUMERIC_WIDTHS)?;
691    let day_period = get("dayPeriod", TEXT_WIDTHS)?;
692    let mut hour = get("hour", NUMERIC_WIDTHS)?;
693    let mut minute = get("minute", NUMERIC_WIDTHS)?;
694    let mut second = get("second", NUMERIC_WIDTHS)?;
695    let fsd = if is_object {
696        let v = crate::builtins::get_property(o, "fractionalSecondDigits")?;
697        if matches!(v, Value::Undef) {
698            None
699        } else {
700            let n = host::to_number_value(&v)?;
701            if n.is_nan() || !(1.0..=3.0).contains(&n) {
702                return Err(host::range_error(
703                    "fractionalSecondDigits value is out of range.",
704                ));
705            }
706            Some(n.floor() as usize)
707        }
708    } else {
709        None
710    };
711    let tz_name = get(
712        "timeZoneName",
713        &[
714            "short",
715            "long",
716            "shortOffset",
717            "longOffset",
718            "shortGeneric",
719            "longGeneric",
720        ],
721    )?;
722    let style_names = &["full", "long", "medium", "short"];
723    let date_style = get("dateStyle", style_names)?;
724    let time_style = get("timeStyle", style_names)?;
725
726    let zone = match time_zone {
727        None => Zone::Local,
728        Some(z) => parse_zone(&z)?,
729    };
730
731    let mut hc = match (hour12, hour_cycle.as_deref()) {
732        (Some(true), _) => Some(Hc::H12),
733        (Some(false), _) => Some(Hc::H23),
734        (None, Some("h11")) => Some(Hc::H11),
735        (None, Some("h12")) => Some(Hc::H12),
736        (None, Some("h23")) => Some(Hc::H23),
737        (None, Some("h24")) => Some(Hc::H24),
738        _ => None,
739    };
740
741    if date_style.is_some() || time_style.is_some() {
742        let explicit = weekday.is_some()
743            || era.is_some()
744            || year.is_some()
745            || month.is_some()
746            || day.is_some()
747            || day_period.is_some()
748            || hour.is_some()
749            || minute.is_some()
750            || second.is_some()
751            || fsd.is_some()
752            || tz_name.is_some();
753        if explicit {
754            return Err(host::type_error("Invalid option : option"));
755        }
756        if required == Required::Date && time_style.is_some() {
757            return Err(host::type_error("Invalid option : timeStyle"));
758        }
759        if required == Required::Time && date_style.is_some() {
760            return Err(host::type_error("Invalid option : dateStyle"));
761        }
762        let index = |s: &Option<String>| {
763            s.as_deref()
764                .and_then(|s| style_names.iter().position(|n| *n == s))
765        };
766        let (ds, ts) = (index(&date_style), index(&time_style));
767        let time = ts.map(|t| {
768            let p = TIME_STYLES[t];
769            match hc {
770                // The style's own `h` stands for the locale default; another
771                // cycle is the style's skeleton regenerated with that hour.
772                None | Some(Hc::H12) => p.to_string(),
773                Some(c) => {
774                    let skel: String = Skeleton::of(p)
775                        .orig
776                        .iter()
777                        .flatten()
778                        .filter(|(ch, _)| *ch != 'a')
779                        .map(|&(ch, n)| {
780                            let ch = if ch == 'h' { hour_char(c) } else { ch };
781                            ch.to_string().repeat(n)
782                        })
783                        .collect();
784                    best_pattern(&skel)
785                }
786            }
787        });
788        let pattern = match (ds, time) {
789            (Some(d), Some(t)) => glue(d, DATE_STYLES[d], &t),
790            (Some(d), None) => DATE_STYLES[d].to_string(),
791            (None, Some(t)) => t,
792            (None, None) => unreachable!("a style was given"),
793        };
794        return Ok(Format {
795            pattern: replace_hour_cycle(&pattern, hc),
796            zone,
797        });
798    }
799
800    // ToDateTimeOptions.
801    let mut need_defaults = true;
802    if matches!(required, Required::Date | Required::Any)
803        && (weekday.is_some() || year.is_some() || month.is_some() || day.is_some())
804    {
805        need_defaults = false;
806    }
807    if matches!(required, Required::Time | Required::Any)
808        && (day_period.is_some()
809            || hour.is_some()
810            || minute.is_some()
811            || second.is_some()
812            || fsd.is_some())
813    {
814        need_defaults = false;
815    }
816    if need_defaults && matches!(required, Required::Date | Required::Any) {
817        year = Some("numeric".into());
818        month = Some("numeric".into());
819        day = Some("numeric".into());
820    }
821    if need_defaults && matches!(required, Required::Time | Required::Any) {
822        hour = Some("numeric".into());
823        minute = Some("numeric".into());
824        second = Some("numeric".into());
825        // An hour the caller did not ask for follows only the 12/24 split of
826        // the cycle: node prints `12:00:00 AM` under `h11` and `00:00:00` under
827        // `h24` for a defaulted hour, and `0`/`24` only for an explicit one.
828        hc = hc.map(|c| match c {
829            Hc::H11 | Hc::H12 => Hc::H12,
830            Hc::H23 | Hc::H24 => Hc::H23,
831        });
832    }
833
834    let mut skel = String::new();
835    let mut push = |c: char, n: usize| skel.push_str(&c.to_string().repeat(n));
836    let text_len = |w: &str| match w {
837        "narrow" => 5,
838        "long" => 4,
839        _ => 3,
840    };
841    if let Some(w) = &era {
842        push('G', text_len(w));
843    }
844    if let Some(w) = &year {
845        push('y', if w == "2-digit" { 2 } else { 1 });
846    }
847    if let Some(w) = &month {
848        push(
849            'M',
850            match w.as_str() {
851                "numeric" => 1,
852                "2-digit" => 2,
853                w => text_len(w),
854            },
855        );
856    }
857    if let Some(w) = &weekday {
858        push('E', text_len(w));
859    }
860    if let Some(w) = &day {
861        push('d', if w == "2-digit" { 2 } else { 1 });
862    }
863    if let Some(w) = &day_period {
864        push('B', text_len(w));
865    }
866    if let Some(w) = &hour {
867        push(
868            hour_char(hc.unwrap_or(Hc::H12)),
869            if w == "2-digit" { 2 } else { 1 },
870        );
871    }
872    if let Some(w) = &minute {
873        push('m', if w == "2-digit" { 2 } else { 1 });
874    }
875    if let Some(w) = &second {
876        push('s', if w == "2-digit" { 2 } else { 1 });
877    }
878    if let Some(n) = fsd {
879        push('S', n);
880    }
881    if let Some(w) = &tz_name {
882        let (c, n) = match w.as_str() {
883            "short" => ('z', 1),
884            "long" => ('z', 4),
885            "shortOffset" => ('O', 1),
886            "longOffset" => ('O', 4),
887            "shortGeneric" => ('v', 1),
888            _ => ('v', 4),
889        };
890        push(c, n);
891    }
892    let pattern = best_pattern(&skel);
893    Ok(Format {
894        pattern: replace_hour_cycle(&pattern, if hour.is_some() { hc } else { None }),
895        zone,
896    })
897}
898
899fn hour_char(hc: Hc) -> char {
900    match hc {
901        Hc::H11 => 'K',
902        Hc::H12 => 'h',
903        Hc::H23 => 'H',
904        Hc::H24 => 'k',
905    }
906}
907
908/// V8's `ReplaceHourCycleInPattern`: every hour field outside quotes takes the
909/// requested cycle's letter.
910fn replace_hour_cycle(pattern: &str, hc: Option<Hc>) -> String {
911    let Some(hc) = hc else {
912        return pattern.to_string();
913    };
914    let want = hour_char(hc);
915    let mut quoted = false;
916    pattern
917        .chars()
918        .map(|c| match c {
919            '\'' => {
920                quoted = !quoted;
921                c
922            }
923            'H' | 'h' | 'K' | 'k' if !quoted => want,
924            _ => c,
925        })
926        .collect()
927}
928
929/// The `timeZone` option: UTC under any of its names, or a fixed offset.
930/// A named IANA zone is not modelled and is reported as invalid.
931fn parse_zone(z: &str) -> Result<Zone, String> {
932    let upper = z.to_ascii_uppercase();
933    const UTC_NAMES: &[&str] = &[
934        "UTC",
935        "ETC/UTC",
936        "GMT",
937        "ETC/GMT",
938        "UCT",
939        "ETC/UCT",
940        "ETC/UNIVERSAL",
941        "UNIVERSAL",
942        "ETC/ZULU",
943        "ZULU",
944        "ETC/GREENWICH",
945        "GREENWICH",
946        "GMT0",
947        "ETC/GMT0",
948        "GMT+0",
949        "GMT-0",
950        "ETC/GMT+0",
951        "ETC/GMT-0",
952    ];
953    if UTC_NAMES.contains(&upper.as_str()) {
954        return Ok(Zone::Utc);
955    }
956    // `Etc/GMT+5` is five hours WEST of Greenwich.
957    if let Some(rest) = upper.strip_prefix("ETC/GMT") {
958        let (sign, digits) = rest.split_at(1.min(rest.len()));
959        if let (Some(s), Ok(h)) = (
960            match sign {
961                "+" => Some(-1),
962                "-" => Some(1),
963                _ => None,
964            },
965            digits.parse::<i64>(),
966        ) {
967            if !digits.is_empty() && digits.len() <= 2 && h <= 14 && !digits.starts_with('0') {
968                return Ok(Zone::Fixed(s * h * 60));
969            }
970        }
971    }
972    // `±HH:MM`, `±HHMM`, `±HH`.
973    let b = z.as_bytes();
974    if matches!(b.first(), Some(b'+') | Some(b'-')) {
975        let sign = if b[0] == b'-' { -1 } else { 1 };
976        let rest = &z[1..];
977        let digits: String = rest.chars().filter(|c| *c != ':').collect();
978        let well_formed = match rest.len() {
979            2 => rest.bytes().all(|c| c.is_ascii_digit()),
980            4 => rest.bytes().all(|c| c.is_ascii_digit()),
981            5 => rest.as_bytes()[2] == b':' && digits.bytes().all(|c| c.is_ascii_digit()),
982            _ => false,
983        };
984        if well_formed {
985            let h: i64 = digits[..2].parse().unwrap_or(99);
986            let m: i64 = digits
987                .get(2..)
988                .filter(|s| !s.is_empty())
989                .map_or(Ok(0), str::parse)
990                .unwrap_or(99);
991            if h <= 23 && m <= 59 {
992                return Ok(Zone::Fixed(sign * (h * 60 + m)));
993            }
994        }
995    }
996    match crate::tzif::load(z) {
997        Some(tz) => Ok(Zone::Named(tz)),
998        None => Err(host::range_error(&format!(
999            "Invalid time zone specified: {z}"
1000        ))),
1001    }
1002}
1003
1004// ── rendering ────────────────────────────────────────────────────────────────
1005
1006const MONTHS_LONG: [&str; 12] = [
1007    "January",
1008    "February",
1009    "March",
1010    "April",
1011    "May",
1012    "June",
1013    "July",
1014    "August",
1015    "September",
1016    "October",
1017    "November",
1018    "December",
1019];
1020const DAYS_LONG: [&str; 7] = [
1021    "Sunday",
1022    "Monday",
1023    "Tuesday",
1024    "Wednesday",
1025    "Thursday",
1026    "Friday",
1027    "Saturday",
1028];
1029
1030/// The broken-down wall clock a pattern reads its fields from.
1031struct Fields {
1032    year: i64,
1033    month: usize,
1034    day: i64,
1035    weekday: usize,
1036    hour: i64,
1037    minute: i64,
1038    second: i64,
1039    millis: i64,
1040    /// Offset east of UTC, in seconds (a pre-1900 local mean time is not a
1041    /// whole number of minutes).
1042    offset_sec: i64,
1043    /// The zone's abbreviation at this instant (`EST`), from 1970 on; `None`
1044    /// for UTC, a fixed offset, or an earlier instant.
1045    abbrev: Option<String>,
1046    /// Whether the zone is UTC itself, which has names of its own.
1047    utc: bool,
1048}
1049
1050impl Format {
1051    /// Render the time value `ms` (finite) with this format.
1052    pub fn format(&self, ms: f64) -> String {
1053        let (offset_ms, abbrev) = match &self.zone {
1054            Zone::Local => (
1055                crate::stdlib::date::zone_offset_ms(ms),
1056                crate::stdlib::date::zone_abbrev(ms),
1057            ),
1058            Zone::Utc => (0.0, None),
1059            Zone::Fixed(m) => (*m as f64 * 60_000.0, None),
1060            Zone::Named(tz) => {
1061                let ty = tz.at((ms / 1000.0).floor() as i64);
1062                (ty.offset as f64 * 1000.0, Some(ty.abbrev))
1063            }
1064        };
1065        let local = ms + offset_ms;
1066        let (day_num, rem) = crate::stdlib::date::split_day(local);
1067        let (year, month, day) = crate::stdlib::date::civil_from_days(day_num);
1068        let f = Fields {
1069            year,
1070            month: month as usize,
1071            day,
1072            weekday: (((day_num % 7) + 4 + 7) % 7) as usize,
1073            hour: rem / 3_600_000,
1074            minute: rem / 60_000 % 60,
1075            second: rem / 1000 % 60,
1076            millis: rem % 1000,
1077            offset_sec: (offset_ms / 1000.0) as i64,
1078            // ICU maps a zone to its named metazone only from 1970 on;
1079            // before that every zone is named by its offset.
1080            utc: match &self.zone {
1081                Zone::Utc => true,
1082                Zone::Fixed(_) => false,
1083                Zone::Local => offset_ms == 0.0 && abbrev.as_deref().map_or(true, |a| a == "UTC"),
1084                Zone::Named(_) => offset_ms == 0.0 && abbrev.as_deref() == Some("UTC"),
1085            },
1086            abbrev: abbrev.filter(|_| ms >= 0.0),
1087        };
1088        let pattern_has_minute = self.pattern.contains('m');
1089        let pattern_has_second = self.pattern.contains('s');
1090        let mut out = String::new();
1091        for t in tokenize(&self.pattern) {
1092            match t {
1093                Tok::Lit(s) => {
1094                    if s.len() >= 2 && s.starts_with('\'') {
1095                        let inner = &s[1..s.len() - 1];
1096                        if inner.is_empty() {
1097                            out.push('\'');
1098                        } else {
1099                            out.push_str(&inner.replace("''", "'"));
1100                        }
1101                    } else {
1102                        out.push_str(&s);
1103                    }
1104                }
1105                Tok::Field(c, n) => {
1106                    out.push_str(&self.field(c, n, &f, pattern_has_minute, pattern_has_second))
1107                }
1108            }
1109        }
1110        out
1111    }
1112
1113    fn field(&self, c: char, n: usize, f: &Fields, has_min: bool, has_sec: bool) -> String {
1114        let num = |v: i64, n: usize| format!("{:0width$}", v, width = n);
1115        let bc = f.year <= 0;
1116        match c {
1117            'G' => match (n, bc) {
1118                (1..=3, false) => "AD".into(),
1119                (1..=3, true) => "BC".into(),
1120                (4, false) => "Anno Domini".into(),
1121                (4, true) => "Before Christ".into(),
1122                (_, false) => "A".into(),
1123                (_, true) => "B".into(),
1124            },
1125            'y' => {
1126                let y = if bc { 1 - f.year } else { f.year };
1127                if n == 2 {
1128                    num(y % 100, 2)
1129                } else {
1130                    num(y, n)
1131                }
1132            }
1133            'M' | 'L' => match n {
1134                1 | 2 => num(f.month as i64 + 1, n),
1135                3 => MONTHS_LONG[f.month][..3].to_string(),
1136                4 => MONTHS_LONG[f.month].to_string(),
1137                _ => MONTHS_LONG[f.month][..1].to_string(),
1138            },
1139            'E' | 'c' => match n {
1140                4 => DAYS_LONG[f.weekday].to_string(),
1141                5 => DAYS_LONG[f.weekday][..1].to_string(),
1142                6 => DAYS_LONG[f.weekday][..2].to_string(),
1143                _ => DAYS_LONG[f.weekday][..3].to_string(),
1144            },
1145            'd' => num(f.day, n),
1146            'a' => match (n, f.hour < 12) {
1147                (5, true) => "a".into(),
1148                (5, false) => "p".into(),
1149                (_, true) => "AM".into(),
1150                (_, false) => "PM".into(),
1151            },
1152            'B' => {
1153                // en's flexible day periods. ICU never says "midnight"; it says
1154                // "noon" only when nothing finer than the hour is shown or
1155                // what is shown is zero.
1156                let noon =
1157                    f.hour == 12 && (!has_min || f.minute == 0) && (!has_sec || f.second == 0);
1158                if noon {
1159                    return if n == 5 { "n".into() } else { "noon".into() };
1160                }
1161                match f.hour {
1162                    0..=11 => "in the morning",
1163                    12..=17 => "in the afternoon",
1164                    18..=20 => "in the evening",
1165                    _ => "at night",
1166                }
1167                .into()
1168            }
1169            'h' => num(if f.hour % 12 == 0 { 12 } else { f.hour % 12 }, n),
1170            'K' => num(f.hour % 12, n),
1171            'H' => num(f.hour, n),
1172            'k' => num(if f.hour == 0 { 24 } else { f.hour }, n),
1173            'm' => num(f.minute, n),
1174            's' => num(f.second, n),
1175            'S' => {
1176                let digits = format!("{:03}", f.millis);
1177                let mut s: String = digits.chars().take(n).collect();
1178                while s.len() < n {
1179                    s.push('0');
1180                }
1181                s
1182            }
1183            'z' | 'O' | 'v' => self.zone_name(c, n, f),
1184            _ => c.to_string().repeat(n),
1185        }
1186    }
1187
1188    /// The `timeZoneName` text. UTC has names of its own, and so do the zones
1189    /// in [`named_zone`]; any other zone is named by its offset
1190    /// (node knows the long names of every zone from ICU; this does not).
1191    fn zone_name(&self, c: char, n: usize, f: &Fields) -> String {
1192        let long = n >= 4;
1193        let utc = f.utc;
1194        let named = f.abbrev.as_deref().and_then(named_zone);
1195        match (c, utc, named) {
1196            ('z', true, _) if long => "Coordinated Universal Time".into(),
1197            ('z', true, _) => "UTC".into(),
1198            ('z', false, Some((_, long_name, _, _))) if long => long_name.into(),
1199            ('z', false, Some((Some(short), _, _, _))) => short.into(),
1200            ('v', false, Some((_, _, _, generic_long))) if long => generic_long.into(),
1201            ('v', false, Some((_, _, generic, _))) => generic.into(),
1202            _ => gmt_offset(f.offset_sec, long),
1203        }
1204    }
1205}
1206
1207/// `GMT-5`, `GMT+5:30` (short) or `GMT-05:00` (long); `GMT+0` / `GMT+00:00` at
1208/// zero, and a seconds part when the offset has one (`GMT-4:56:02`).
1209fn gmt_offset(secs: i64, long: bool) -> String {
1210    let sign = if secs < 0 { '-' } else { '+' };
1211    let a = secs.abs();
1212    let (h, m, s) = (a / 3600, a / 60 % 60, a % 60);
1213    let tail = if s != 0 {
1214        format!(":{s:02}")
1215    } else {
1216        String::new()
1217    };
1218    if long {
1219        format!("GMT{sign}{h:02}:{m:02}{tail}")
1220    } else if m == 0 && s == 0 {
1221        format!("GMT{sign}{h}")
1222    } else {
1223        format!("GMT{sign}{h}:{m:02}{tail}")
1224    }
1225}
1226
1227/// The zones en names by the abbreviation `localtime_r` reports:
1228/// `(short, long, short generic, long generic)`, `None` where en writes the
1229/// GMT offset instead.
1230type ZoneNames = (
1231    Option<&'static str>,
1232    &'static str,
1233    &'static str,
1234    &'static str,
1235);
1236
1237fn named_zone(abbrev: &str) -> Option<ZoneNames> {
1238    let us = |short, long, region: &'static str| -> ZoneNames {
1239        let generic: &'static str = match region {
1240            "Eastern" => "ET",
1241            "Central" => "CT",
1242            "Mountain" => "MT",
1243            "Pacific" => "PT",
1244            _ => "AKT",
1245        };
1246        let generic_long: &'static str = match region {
1247            "Eastern" => "Eastern Time",
1248            "Central" => "Central Time",
1249            "Mountain" => "Mountain Time",
1250            "Pacific" => "Pacific Time",
1251            _ => "Alaska Time",
1252        };
1253        (Some(short), long, generic, generic_long)
1254    };
1255    Some(match abbrev {
1256        "EST" => us("EST", "Eastern Standard Time", "Eastern"),
1257        "EDT" => us("EDT", "Eastern Daylight Time", "Eastern"),
1258        "CST" => us("CST", "Central Standard Time", "Central"),
1259        "CDT" => us("CDT", "Central Daylight Time", "Central"),
1260        "MST" => us("MST", "Mountain Standard Time", "Mountain"),
1261        "MDT" => us("MDT", "Mountain Daylight Time", "Mountain"),
1262        "PST" => us("PST", "Pacific Standard Time", "Pacific"),
1263        "PDT" => us("PDT", "Pacific Daylight Time", "Pacific"),
1264        "AKST" => us("AKST", "Alaska Standard Time", "Alaska"),
1265        "AKDT" => us("AKDT", "Alaska Daylight Time", "Alaska"),
1266        // Hawaii keeps no daylight time, so its generic names are its
1267        // standard ones.
1268        "HST" => (
1269            Some("HST"),
1270            "Hawaii-Aleutian Standard Time",
1271            "HST",
1272            "Hawaii-Aleutian Standard Time",
1273        ),
1274        "GMT" => (
1275            Some("GMT"),
1276            "Greenwich Mean Time",
1277            "GMT",
1278            "Greenwich Mean Time",
1279        ),
1280        _ => return None,
1281    })
1282}
1283
1284/// `Date.prototype.toLocale{,Date,Time}String(locales, options)`.
1285pub fn date_to_locale(
1286    ms: f64,
1287    opts: &Value,
1288    required: Required,
1289    method: &str,
1290) -> Result<String, String> {
1291    if ms.is_nan() {
1292        return Ok("Invalid Date".into());
1293    }
1294    Ok(resolve(opts, required, method)?.format(ms))
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299    use super::*;
1300
1301    #[test]
1302    fn patterns_match_icu() {
1303        assert_eq!(best_pattern("yMd"), "M/d/y");
1304        assert_eq!(best_pattern("yMMMMEEEEd"), "EEEE, MMMM d, y");
1305        assert_eq!(best_pattern("hms"), "h:mm:ss a");
1306        assert_eq!(best_pattern("yMdhms"), "M/d/y, h:mm:ss a");
1307        assert_eq!(best_pattern("Hms"), "HH:mm:ss");
1308        assert_eq!(best_pattern("MMMMMh"), "LLLLL, h a");
1309        assert_eq!(best_pattern("ss"), "s");
1310        assert_eq!(best_pattern("ddHHs"), "dd, s ('hour': HH)");
1311    }
1312}