Skip to main content

nodejs/stdlib/
date.rs

1//! JavaScript `Date` (global constructor). A Date is a plain object tagged
2//! `@@native = "Date"` whose time value (milliseconds since the Unix epoch, or
3//! NaN for an invalid date) lives in a hidden `@@ms` field.
4//!
5//! The UTC-based surface is implemented in full: `getTime`/`valueOf`, the
6//! `toISOString`/`toUTCString`/`toString`/`toDateString`/`toTimeString`/
7//! `toLocale*` renderings, the field getters, the component SETTERS, the Annex-B
8//! `getYear`/`setYear`, and the statics `Date.now`/`Date.parse`/`Date.UTC`.
9//! Local-timezone getters and setters alias the UTC ones (node-js runs as if
10//! `TZ=UTC`), which is the correct answer for the machine-readable date headers
11//! express/send/fresh produce.
12
13use crate::host::{with_host, JsObj};
14use fusevm::Value;
15use indexmap::IndexMap;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18pub const STATIC_METHODS: &[&str] = &["now", "parse", "UTC"];
19
20/// Every method `instance_call` below answers. `valueOf` and `toString` are on
21/// this list because `Object.prototype` has methods by those names too: without
22/// the entry, `host::call_method` hands a Date to `object_builtin_method` and
23/// `date.valueOf()` returns the Date itself instead of its time value (so `+d`
24/// and `d - 0` were `NaN`).
25pub const INSTANCE_METHODS: &[&str] = &[
26    "getTime",
27    "valueOf",
28    "toISOString",
29    "toJSON",
30    "toUTCString",
31    "toGMTString",
32    "toString",
33    "toDateString",
34    "toLocaleString",
35    "toLocaleDateString",
36    "toLocaleTimeString",
37    "getFullYear",
38    "getUTCFullYear",
39    "getMonth",
40    "getUTCMonth",
41    "getDate",
42    "getUTCDate",
43    "getDay",
44    "getUTCDay",
45    "getHours",
46    "getUTCHours",
47    "getMinutes",
48    "getUTCMinutes",
49    "getSeconds",
50    "getUTCSeconds",
51    "getMilliseconds",
52    "getUTCMilliseconds",
53    "getTimezoneOffset",
54    "toTimeString",
55    "setTime",
56    "setFullYear",
57    "setUTCFullYear",
58    "setMonth",
59    "setUTCMonth",
60    "setDate",
61    "setUTCDate",
62    "setHours",
63    "setUTCHours",
64    "setMinutes",
65    "setUTCMinutes",
66    "setSeconds",
67    "setUTCSeconds",
68    "setMilliseconds",
69    "setUTCMilliseconds",
70    "getYear",
71    "setYear",
72    // 21.4.4.45. A Date is the one builtin whose DEFAULT hint is `"string"`,
73    // which is why `date + 1` concatenates while `date - 1` subtracts. The
74    // coercion path already knew that, but the method itself was not exposed,
75    // so `date[Symbol.toPrimitive]` was not a function.
76    "@@toPrimitive",
77];
78
79const MS_PER_DAY: f64 = 86_400_000.0;
80const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
81const MONTHS: [&str; 12] = [
82    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
83];
84
85/// Milliseconds since the Unix epoch, right now.
86fn now_ms() -> f64 {
87    SystemTime::now()
88        .duration_since(UNIX_EPOCH)
89        .map(|d| d.as_millis() as f64)
90        .unwrap_or(0.0)
91}
92
93/// Build a Date value carrying `ms` (NaN → an "Invalid Date").
94fn from_ms(ms: f64) -> Value {
95    with_host(|h| {
96        let mut m = IndexMap::new();
97        m.insert("@@native".into(), h.new_str("Date"));
98        m.insert("@@ms".into(), Value::Float(ms));
99        h.new_object(m)
100    })
101}
102
103/// The stored time value of a Date instance (NaN if not a Date).
104fn ms_of(recv: &Value) -> f64 {
105    with_host(|h| match h.get(recv) {
106        Some(JsObj::Object(p)) => p.get("@@ms").map(|v| h.to_number(v)).unwrap_or(f64::NAN),
107        _ => f64::NAN,
108    })
109}
110
111/// `new Date(...)`.
112pub fn construct(args: &[Value]) -> Result<Value, String> {
113    let ms = match args.len() {
114        0 => now_ms(),
115        1 => {
116            let a = &args[0];
117            // A string argument is parsed; anything else is coerced to a number
118            // (milliseconds). Another Date coerces via its time value.
119            if let Value::Str(_) = a {
120                parse_str(&with_host(|h| h.str_of(a)))
121            } else if with_host(|h| matches!(h.get(a), Some(JsObj::Str(_)))) {
122                parse_str(&with_host(|h| h.str_of(a)))
123            } else if super::native_tag(a).as_deref() == Some("Date") {
124                ms_of(a)
125            } else {
126                // 21.4.2.1 step 3.d is `ToNumber(v)` after `ToPrimitive`, and a
127                // SYMBOL refuses it — `new Date(sym)` produced an Invalid Date
128                // instead of throwing.
129                if with_host(|h| matches!(h.get(a), Some(crate::host::JsObj::Symbol { .. }))) {
130                    return Err(crate::host::type_error(
131                        "Cannot convert a Symbol value to a number",
132                    ));
133                }
134                with_host(|h| h.to_number(a))
135            }
136        }
137        // (year, month[, day, hours, minutes, seconds, ms]) — LOCAL time
138        // (21.4.2.1 step 5.k: `UTC(MakeDate(...))`). It was taken as UTC, so
139        // under `TZ=America/New_York` `new Date(2024, 0, 31)` read back as
140        // 19:00 on the 30th, and `new Date(99, 0).getFullYear()` as 1998.
141        _ => {
142            let n = |i: usize, dflt: f64| {
143                args.get(i)
144                    .map(|v| with_host(|h| h.to_number(v)))
145                    .unwrap_or(dflt)
146            };
147            let mut year = n(0, f64::NAN);
148            // Years 0..99 map to 1900..1999 per the spec.
149            if (0.0..=99.0).contains(&year.trunc()) {
150                year = year.trunc() + 1900.0;
151            }
152            utc_from_local(utc_from_fields(
153                year,
154                n(1, 0.0),
155                n(2, 1.0),
156                n(3, 0.0),
157                n(4, 0.0),
158                n(5, 0.0),
159                n(6, 0.0),
160            ))
161        }
162    };
163    // TimeClip (21.4.1.31): a value beyond ±8.64e15 ms is not a representable
164    // date and becomes NaN. `new Date(8.64e15 + 1)` used to keep the raw number
165    // and print a real date where node prints `Invalid Date`.
166    Ok(from_ms(time_clip(ms)))
167}
168
169/// `Date.now()` / `Date.parse(str)` / `Date.UTC(...)`.
170pub fn static_call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
171    Some(match method {
172        "now" => Ok(Value::Float(now_ms())),
173        "parse" => Ok(Value::Float(parse_str(&super::arg_str(args, 0)))),
174        "UTC" => {
175            let n = |i: usize, dflt: f64| {
176                args.get(i)
177                    .map(|v| with_host(|h| h.to_number(v)))
178                    .unwrap_or(dflt)
179            };
180            let mut year = n(0, f64::NAN);
181            if (0.0..=99.0).contains(&year.trunc()) {
182                year = year.trunc() + 1900.0;
183            }
184            Ok(Value::Float(utc_from_fields(
185                year,
186                n(1, 0.0),
187                n(2, 1.0),
188                n(3, 0.0),
189                n(4, 0.0),
190                n(5, 0.0),
191                n(6, 0.0),
192            )))
193        }
194        _ => return None,
195    })
196}
197
198/// Date instance methods (all treated as UTC — see the module note).
199pub fn instance_call(recv: &Value, method: &str, _args: &[Value]) -> Result<Value, String> {
200    // Every `set*` argument is `ToNumber`d (21.4.4.x), which runs a user
201    // `valueOf` and can throw from it. The reads below are infallible and do no
202    // `ToPrimitive`, so `d.setFullYear({valueOf: () => 2020})` produced an
203    // Invalid Date.
204    let coerced: Vec<Value>;
205    let _args: &[Value] = if method.starts_with("set") {
206        let mut out = Vec::with_capacity(_args.len());
207        for a in _args {
208            let p = crate::host::to_primitive(a, "number")?;
209            out.push(Value::Float(with_host(|h| h.to_number(&p))));
210        }
211        coerced = out;
212        &coerced
213    } else {
214        _args
215    };
216    let ms = ms_of(recv);
217    let f = |ms: f64| ms; // readability alias for numeric returns
218    Ok(match method {
219        "getTime" | "valueOf" => Value::Float(f(ms)),
220        // Only an explicit `"number"` hint yields the timestamp; `"string"` and
221        // `"default"` both render the date, which is the rule that makes the
222        // default hint behave as `"string"`.
223        "@@toPrimitive" => {
224            let hint = with_host(|h| h.str_of(&_args.first().cloned().unwrap_or(Value::Undef)));
225            if hint == "number" {
226                Value::Float(f(ms))
227            } else {
228                return instance_call(recv, "toString", _args);
229            }
230        }
231        "toISOString" | "toJSON" => {
232            if ms.is_nan() {
233                if method == "toJSON" {
234                    with_host(|h| h.null())
235                } else {
236                    return Err(crate::host::range_error("Invalid time value"));
237                }
238            } else {
239                with_host(|h| h.new_str(iso_string(ms)))
240            }
241        }
242        "toUTCString" | "toGMTString" => with_host(|h| h.new_str(utc_string(ms))),
243        // `toString` (21.4.4.41) is NOT the RFC-7231 header form — that is
244        // `toUTCString`. It is `ToDateString`: the `toDateString` half, a space,
245        // then the `toTimeString` half. This used to answer `toUTCString`, so
246        // `String(date)` and `` `${date}` `` printed
247        // `Thu, 01 Jan 1970 00:00:00 GMT` where node prints
248        // `Thu Jan 01 1970 00:00:00 GMT+0000 (Coordinated Universal Time)`.
249        "toString" => with_host(|h| {
250            h.new_str(if ms.is_nan() {
251                "Invalid Date".into()
252            } else {
253                format!("{} {}", date_string(local_ms(ms)), time_string(ms))
254            })
255        }),
256        "toDateString" => with_host(|h| h.new_str(date_string(local_ms(ms)))),
257        // The three `toLocale*` forms threw `is not a function` — absent
258        // entirely, so `new Date(0).toLocaleString()` failed where node prints
259        // `1/1/1970, 12:00:00 AM`. Rendered in node's default en-US shape
260        // (`M/D/YYYY` and 12-hour `h:mm:ss AM/PM`) at UTC, consistent with the
261        // rest of this module running as if `TZ=UTC`. The `locales`/`options`
262        // arguments are accepted and ignored: without ICU there is nothing to
263        // vary, and answering the default form beats throwing.
264        "toLocaleString" => with_host(|h| {
265            h.new_str(if ms.is_nan() {
266                "Invalid Date".into()
267            } else {
268                format!(
269                    "{}, {}",
270                    locale_date(local_ms(ms)),
271                    locale_time(local_ms(ms))
272                )
273            })
274        }),
275        "toLocaleDateString" => with_host(|h| {
276            h.new_str(if ms.is_nan() {
277                "Invalid Date".into()
278            } else {
279                locale_date(local_ms(ms))
280            })
281        }),
282        "toLocaleTimeString" => with_host(|h| {
283            h.new_str(if ms.is_nan() {
284                "Invalid Date".into()
285            } else {
286                locale_time(local_ms(ms))
287            })
288        }),
289        "getFullYear" => Value::Float(field(local_ms(ms), Field::Year)),
290        "getUTCFullYear" => Value::Float(field(ms, Field::Year)),
291        "getMonth" => Value::Float(field(local_ms(ms), Field::Month)),
292        "getUTCMonth" => Value::Float(field(ms, Field::Month)),
293        "getDate" => Value::Float(field(local_ms(ms), Field::Day)),
294        "getUTCDate" => Value::Float(field(ms, Field::Day)),
295        "getDay" => Value::Float(field(local_ms(ms), Field::Weekday)),
296        "getUTCDay" => Value::Float(field(ms, Field::Weekday)),
297        "getHours" => Value::Float(field(local_ms(ms), Field::Hours)),
298        "getUTCHours" => Value::Float(field(ms, Field::Hours)),
299        "getMinutes" => Value::Float(field(local_ms(ms), Field::Minutes)),
300        "getUTCMinutes" => Value::Float(field(ms, Field::Minutes)),
301        "getSeconds" => Value::Float(field(local_ms(ms), Field::Seconds)),
302        "getUTCSeconds" => Value::Float(field(ms, Field::Seconds)),
303        "getMilliseconds" => Value::Float(field(local_ms(ms), Field::Millis)),
304        "getUTCMilliseconds" => Value::Float(field(ms, Field::Millis)),
305        // 21.4.4.7: minutes WEST of UTC, so the sign is the opposite of the
306        // offset itself — `TZ=America/Detroit` reports 300, not -300.
307        // Computed as `(t - LocalTime(t)) / msPerMinute`, as written, so a zero
308        // offset is +0: negating it printed `-0` under `TZ=UTC`.
309        "getTimezoneOffset" => Value::Float(if ms.is_nan() {
310            f64::NAN
311        } else {
312            (ms - local_ms(ms)) / 60_000.0
313        }),
314        "toTimeString" => with_host(|h| h.new_str(time_string(ms))),
315        "setTime" => Value::Float(store_ms(recv, time_clip(super::arg_num(_args, 0)))),
316        // The component setters (21.4.4.20-21.4.4.28). Each takes its own field
317        // plus every LOWER-order one it can reach, defaulting the rest from the
318        // current time value, then rebuilds and TimeClips. `setUTCFullYear` and
319        // friends were absent entirely, so `d.setUTCFullYear(2000)` threw
320        // `is not a function` — a Date could be read but never modified except
321        // wholesale through `setTime`.
322        "setFullYear" => Value::Float(set_fields_local(recv, ms, 0, _args, false)),
323        "setUTCFullYear" => Value::Float(set_fields(recv, ms, 0, _args, false)),
324        "setMonth" => Value::Float(set_fields_local(recv, ms, 1, _args, false)),
325        "setUTCMonth" => Value::Float(set_fields(recv, ms, 1, _args, false)),
326        "setDate" => Value::Float(set_fields_local(recv, ms, 2, _args, false)),
327        "setUTCDate" => Value::Float(set_fields(recv, ms, 2, _args, false)),
328        "setHours" => Value::Float(set_fields_local(recv, ms, 3, _args, false)),
329        "setUTCHours" => Value::Float(set_fields(recv, ms, 3, _args, false)),
330        "setMinutes" => Value::Float(set_fields_local(recv, ms, 4, _args, false)),
331        "setUTCMinutes" => Value::Float(set_fields(recv, ms, 4, _args, false)),
332        "setSeconds" => Value::Float(set_fields_local(recv, ms, 5, _args, false)),
333        "setUTCSeconds" => Value::Float(set_fields(recv, ms, 5, _args, false)),
334        "setMilliseconds" => Value::Float(set_fields_local(recv, ms, 6, _args, false)),
335        "setUTCMilliseconds" => Value::Float(set_fields(recv, ms, 6, _args, false)),
336        // Annex B B.2.3.3 / B.2.3.4 — offset-from-1900 year accessors kept for
337        // legacy code. `setYear` maps 0..99 onto 1900..1999, which is the only
338        // way it differs from `setFullYear`.
339        // Annex B's pair is LOCAL, like `getFullYear`/`setFullYear`.
340        "getYear" => Value::Float(if ms.is_nan() {
341            f64::NAN
342        } else {
343            field(local_ms(ms), Field::Year) - 1900.0
344        }),
345        "setYear" => Value::Float(set_fields_local(recv, ms, 0, _args, true)),
346        _ => {
347            return Err(crate::host::type_error(&format!(
348                "date.{method} is not a function"
349            )))
350        }
351    })
352}
353
354// ── civil-calendar conversions (days-from-epoch ⇄ Y/M/D), UTC only ────────────
355
356enum Field {
357    Year,
358    Month,
359    Day,
360    Weekday,
361    Hours,
362    Minutes,
363    Seconds,
364    Millis,
365}
366
367/// Split a time value into (days-from-epoch, ms-within-day), flooring toward -∞
368/// so negative (pre-1970) times decompose correctly.
369fn split_day(ms: f64) -> (i64, i64) {
370    let day = (ms / MS_PER_DAY).floor();
371    let rem = ms - day * MS_PER_DAY;
372    (day as i64, rem as i64)
373}
374
375/// Convert a days-from-epoch count to (year, month 0-11, day 1-31) using
376/// Howard Hinnant's civil_from_days algorithm.
377fn civil_from_days(z: i64) -> (i64, i64, i64) {
378    let z = z + 719_468;
379    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
380    let doe = z - era * 146_097; // [0, 146096]
381    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
382    let y = yoe + era * 400;
383    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
384    let mp = (5 * doy + 2) / 153; // [0, 11]
385    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
386    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
387    (if m <= 2 { y + 1 } else { y }, m - 1, d)
388}
389
390/// Inverse: (year, month 0-11, day) → days from epoch.
391fn days_from_civil(y: i64, m0: i64, d: i64) -> i64 {
392    let m = m0 + 1;
393    let y = if m <= 2 { y - 1 } else { y };
394    let era = if y >= 0 { y } else { y - 399 } / 400;
395    let yoe = y - era * 400;
396    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
397    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
398    era * 146_097 + doe - 719_468
399}
400
401fn field(ms: f64, which: Field) -> f64 {
402    if ms.is_nan() {
403        return f64::NAN;
404    }
405    let (day, rem) = split_day(ms);
406    let (y, mo, d) = civil_from_days(day);
407    match which {
408        Field::Year => y as f64,
409        Field::Month => mo as f64,
410        Field::Day => d as f64,
411        // Weekday: 1970-01-01 (day 0) was a Thursday (4).
412        Field::Weekday => (((day % 7) + 4 + 7) % 7) as f64,
413        Field::Hours => (rem / 3_600_000) as f64,
414        Field::Minutes => (rem / 60_000 % 60) as f64,
415        Field::Seconds => (rem / 1000 % 60) as f64,
416        Field::Millis => (rem % 1000) as f64,
417    }
418}
419
420/// Assemble a UTC time value from broken-down fields (with month/day overflow
421/// normalized the way JS does, e.g. month 12 rolls into the next year).
422fn utc_from_fields(y: f64, mo: f64, d: f64, h: f64, mi: f64, s: f64, ms: f64) -> f64 {
423    // MakeDay / MakeTime (21.4.1.28-29): a non-finite field is NaN, and every
424    // field is `ToIntegerOrInfinity`d first — so `new Date(2024, 0, 1, 1.5)` is
425    // 01:00, not 01:30, and `Date.UTC(1970, 0, 1, 0, 0, 0, 0.9)` is 0.
426    if [y, mo, d, h, mi, s, ms].iter().any(|v| !v.is_finite()) {
427        return f64::NAN;
428    }
429    let [y, mo, d, h, mi, s, ms] = [y, mo, d, h, mi, s, ms].map(f64::trunc);
430    // Normalize month into 0..11, carrying into the year.
431    let total_months = y as i64 * 12 + mo as i64;
432    let year = total_months.div_euclid(12);
433    let month = total_months.rem_euclid(12);
434    let days = days_from_civil(year, month, d as i64);
435    days as f64 * MS_PER_DAY + h * 3_600_000.0 + mi * 60_000.0 + s * 1000.0 + ms
436}
437
438/// `Wed, 21 Oct 2015 07:28:00 GMT` — the RFC-7231 IMF-fixdate HTTP header form.
439/// The zone offset in MILLISECONDS east of UTC that applies at `ms`, from the
440/// C library's `localtime_r` — which reads `TZ` exactly as node does and is
441/// DST-aware per timestamp rather than per zone.
442///
443/// Everything local used to be UTC: `getTimezoneOffset()` answered 0, each
444/// local getter shared its arm with the `getUTC*` one, and `toString` rendered
445/// the UTC wall clock. Under `TZ=America/Detroit` that made
446/// `new Date(0).getMonth()` 0 where node says 11. The parity harness pins
447/// `TZ=UTC` for both sides, which is why no record ever caught it.
448#[cfg(unix)]
449fn zone_offset_ms(ms: f64) -> f64 {
450    if !ms.is_finite() {
451        return 0.0;
452    }
453    // `localtime_r` takes SECONDS; flooring keeps a pre-epoch timestamp in the
454    // right second rather than rounding it toward zero.
455    let secs = (ms / 1000.0).floor() as i64;
456    let t = secs as libc::time_t;
457    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
458    // SAFETY: `localtime_r` writes into the caller's `tm` and reads only `t`.
459    let ok = unsafe { !libc::localtime_r(&t, &mut tm).is_null() };
460    if !ok {
461        return 0.0;
462    }
463    tm.tm_gmtoff as f64 * 1000.0
464}
465
466#[cfg(not(unix))]
467fn zone_offset_ms(_ms: f64) -> f64 {
468    0.0
469}
470
471/// The local wall-clock time value for `ms` — what every local getter reads its
472/// fields out of.
473fn local_ms(ms: f64) -> f64 {
474    ms + zone_offset_ms(ms)
475}
476
477/// The inverse: a local wall-clock time value back to a timestamp.
478///
479/// The offset depends on the instant, so one lookup is not enough near a DST
480/// transition. Two candidates are built — using the offset at the naive guess
481/// and at the corrected one — and the one that reads BACK as the requested
482/// local time wins. A spring-forward GAP has no such candidate, because the
483/// wall clock never showed that time; 21.4.1.26 leaves the choice to the
484/// implementation and V8 takes the offset from BEFORE the transition, which
485/// pushes the result past it: local 02:30 on a spring-forward day is 03:30.
486fn utc_from_local(local: f64) -> f64 {
487    if !local.is_finite() {
488        return local;
489    }
490    let off_naive = zone_offset_ms(local);
491    let cand_a = local - off_naive;
492    let off_corrected = zone_offset_ms(cand_a);
493    if off_corrected == off_naive {
494        return cand_a;
495    }
496    let cand_b = local - off_corrected;
497    if local_ms(cand_b) == local {
498        return cand_b;
499    }
500    if local_ms(cand_a) == local {
501        return cand_a;
502    }
503    local - off_naive.min(off_corrected)
504}
505
506fn utc_string(ms: f64) -> String {
507    if ms.is_nan() {
508        return "Invalid Date".into();
509    }
510    let (day, _) = split_day(ms);
511    let (y, mo, d) = civil_from_days(day);
512    let wd = (((day % 7) + 4 + 7) % 7) as usize;
513    format!(
514        "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
515        DAYS[wd],
516        d,
517        MONTHS[mo as usize],
518        y,
519        field(ms, Field::Hours) as i64,
520        field(ms, Field::Minutes) as i64,
521        field(ms, Field::Seconds) as i64,
522    )
523}
524
525/// `00:00:00 GMT+0000 (Coordinated Universal Time)` — the `toTimeString` form
526/// (21.4.4.42 TimeString + TimeZoneString). The clock is LOCAL and the offset
527/// is the zone's real one; both were fixed at UTC before.
528///
529/// The parenthetical is the zone's LONG name, which node takes from ICU. There
530/// is none here, so only UTC — the one name that is not data — is spelled out
531/// and every other zone reports the abbreviation `localtime_r` supplies
532/// (`GMT-0500 (EST)` where node writes `(Eastern Standard Time)`). Recorded in
533/// BUGS.md.
534fn time_string(ms: f64) -> String {
535    if ms.is_nan() {
536        return "Invalid Date".into();
537    }
538    let local = local_ms(ms);
539    let off_min = (zone_offset_ms(ms) / 60_000.0) as i64;
540    let sign = if off_min < 0 { '-' } else { '+' };
541    let abs = off_min.abs();
542    format!(
543        "{:02}:{:02}:{:02} GMT{}{:02}{:02} ({})",
544        field(local, Field::Hours) as i64,
545        field(local, Field::Minutes) as i64,
546        field(local, Field::Seconds) as i64,
547        sign,
548        abs / 60,
549        abs % 60,
550        zone_name(ms),
551    )
552}
553
554/// The zone's display name for `toString`. UTC is spelled out the way node
555/// does; anything else falls back to the abbreviation.
556#[cfg(unix)]
557fn zone_name(ms: f64) -> String {
558    if zone_offset_ms(ms) == 0.0 {
559        return "Coordinated Universal Time".into();
560    }
561    let secs = (ms / 1000.0).floor() as i64;
562    let t = secs as libc::time_t;
563    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
564    // SAFETY: as in `zone_offset_ms`.
565    if unsafe { libc::localtime_r(&t, &mut tm).is_null() } || tm.tm_zone.is_null() {
566        return "Coordinated Universal Time".into();
567    }
568    // SAFETY: `tm_zone` points at a static zone-name string owned by libc.
569    let z = unsafe { std::ffi::CStr::from_ptr(tm.tm_zone) };
570    z.to_string_lossy().into_owned()
571}
572
573#[cfg(not(unix))]
574fn zone_name(_ms: f64) -> String {
575    "Coordinated Universal Time".into()
576}
577
578/// TimeClip (21.4.1.31): a time value more than 8.64e15 ms from the epoch is not
579/// representable and becomes NaN; anything else truncates toward zero.
580///
581/// Without this a `new Date(8.64e15 + 1)` kept the out-of-range value and
582/// printed a real date (`Sat, 13 Sep 275760 …`) where node prints
583/// `Invalid Date`, so the boundary every date-range check relies on was absent.
584fn time_clip(ms: f64) -> f64 {
585    if !ms.is_finite() || ms.abs() > 8.64e15 {
586        return f64::NAN;
587    }
588    ms.trunc()
589}
590
591/// Write a time value into the receiver's hidden `@@ms` slot, returning it (as
592/// every mutator does).
593fn store_ms(recv: &Value, ms: f64) -> f64 {
594    with_host(|h| {
595        if let Some(JsObj::Object(p)) = h.get_mut(recv) {
596            p.insert("@@ms".into(), Value::Float(ms));
597        }
598    });
599    ms
600}
601
602/// The shared body of every component setter.
603///
604/// `start` indexes the field the setter names within
605/// `[year, month, date, hours, minutes, seconds, ms]`; the setter consumes that
606/// field and every LOWER-order one in its own group (date fields 0..2, time
607/// fields 3..6), defaulting anything not supplied from the current time value.
608///
609/// `legacy_year` applies Annex B `setYear`'s 0..99 → 1900..1999 mapping.
610///
611/// NaN handling follows the spec's split: `setFullYear` on an invalid date
612/// treats the time value as +0 and so can REVIVE it (21.4.4.21 step 2), while
613/// every other setter leaves an invalid date invalid.
614/// `set_fields` on the LOCAL wall clock: read the current fields in local time,
615/// replace the ones given, then convert the result back to a timestamp.
616fn set_fields_local(recv: &Value, ms: f64, start: usize, args: &[Value], legacy_year: bool) -> f64 {
617    if ms.is_nan() && start != 0 {
618        return store_ms(recv, f64::NAN);
619    }
620    let local = set_fields_value(local_ms(ms), start, args, legacy_year);
621    store_ms(recv, time_clip(utc_from_local(local)))
622}
623
624fn set_fields(recv: &Value, ms: f64, start: usize, args: &[Value], legacy_year: bool) -> f64 {
625    if ms.is_nan() && start != 0 {
626        return store_ms(recv, f64::NAN);
627    }
628    store_ms(
629        recv,
630        time_clip(set_fields_value(ms, start, args, legacy_year)),
631    )
632}
633
634/// The field replacement itself, on whatever time value it is handed — the
635/// local wall clock for a `setHours`, the timestamp for a `setUTCHours`. Split
636/// out so the two differ only in what they pass in and what they do with the
637/// result.
638fn set_fields_value(ms: f64, start: usize, args: &[Value], legacy_year: bool) -> f64 {
639    let base = if ms.is_nan() {
640        0.0 // setFullYear/setYear on an Invalid Date starts from the epoch.
641    } else {
642        ms
643    };
644    let mut f = [
645        field(base, Field::Year),
646        field(base, Field::Month),
647        field(base, Field::Day),
648        field(base, Field::Hours),
649        field(base, Field::Minutes),
650        field(base, Field::Seconds),
651        field(base, Field::Millis),
652    ];
653    // A date setter reaches at most field 2; a time setter at most field 6.
654    let end = if start < 3 { 3 } else { 7 };
655    for (i, slot) in f.iter_mut().enumerate().take(end).skip(start) {
656        match args.get(i - start) {
657            Some(v) => *slot = with_host(|h| h.to_number(v)).trunc(),
658            None => break,
659        }
660    }
661    if legacy_year && (0.0..=99.0).contains(&f[0]) {
662        f[0] += 1900.0;
663    }
664    utc_from_fields(f[0], f[1], f[2], f[3], f[4], f[5], f[6])
665}
666
667/// `Wed Oct 21 2015` — the `toDateString` form.
668fn date_string(ms: f64) -> String {
669    if ms.is_nan() {
670        return "Invalid Date".into();
671    }
672    let (day, _) = split_day(ms);
673    let (y, mo, d) = civil_from_days(day);
674    let wd = (((day % 7) + 4 + 7) % 7) as usize;
675    format!("{} {} {:02} {:04}", DAYS[wd], MONTHS[mo as usize], d, y)
676}
677
678/// `1/2/2020` — the `toLocaleDateString` default (en-US `M/D/YYYY`, no padding).
679fn locale_date(ms: f64) -> String {
680    let (day, _) = split_day(ms);
681    let (y, mo, d) = civil_from_days(day);
682    format!("{}/{}/{:04}", mo + 1, d, y)
683}
684
685/// `3:04:05 PM` — the `toLocaleTimeString` default (en-US 12-hour). Hour 0 and
686/// hour 12 both render as `12`, which is why this is not `h % 12`.
687fn locale_time(ms: f64) -> String {
688    let h24 = field(ms, Field::Hours) as i64;
689    let (h12, meridiem) = match h24 {
690        0 => (12, "AM"),
691        1..=11 => (h24, "AM"),
692        12 => (12, "PM"),
693        _ => (h24 - 12, "PM"),
694    };
695    format!(
696        "{}:{:02}:{:02} {}",
697        h12,
698        field(ms, Field::Minutes) as i64,
699        field(ms, Field::Seconds) as i64,
700        meridiem
701    )
702}
703
704/// The year field of an ISO-8601 date (21.4.4.36 `Date.prototype.toISOString`).
705///
706/// Years 0..=9999 are four digits; anything outside that range uses the EXPANDED
707/// form — an explicit sign and exactly six digits, `+275760` / `-000001`. A bare
708/// `{:04}` gets both wrong, since Rust counts the sign inside the width (`-1`
709/// formats as `-001`) and never emits `+`.
710fn iso_year(y: i64) -> String {
711    if (0..=9999).contains(&y) {
712        return format!("{y:04}");
713    }
714    let sign = if y < 0 { '-' } else { '+' };
715    format!("{sign}{:06}", y.abs())
716}
717
718/// `2015-10-21T07:28:00.000Z` — the ISO-8601 / `toISOString` form.
719fn iso_string(ms: f64) -> String {
720    let (day, _) = split_day(ms);
721    let (y, mo, d) = civil_from_days(day);
722    format!(
723        "{}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
724        iso_year(y),
725        mo + 1,
726        d,
727        field(ms, Field::Hours) as i64,
728        field(ms, Field::Minutes) as i64,
729        field(ms, Field::Seconds) as i64,
730        field(ms, Field::Millis) as i64,
731    )
732}
733
734/// Parse a date string: the Date Time String Format first ([`parse_iso`]),
735/// then the free-form fallback every engine keeps ([`parse_legacy`]). NaN on
736/// anything neither accepts — the "Invalid Date" contract.
737///
738/// The string is not trimmed first: V8 hands `" 2024-01-01 "` to the fallback,
739/// which reads it as LOCAL midnight rather than the format's UTC one.
740fn parse_str(s: &str) -> f64 {
741    time_clip(parse_iso(s).or_else(|| parse_legacy(s)).unwrap_or(f64::NAN))
742}
743
744/// The Date Time String Format (21.4.1.32): `YYYY[-MM[-DD]]`, optionally
745/// followed by `THH:mm[:ss[.sss]]`, optionally followed by a zone — `Z` or
746/// `±HH:mm` (V8 also takes `±HHmm`). The year may be the expanded `±YYYYYY`.
747///
748/// A date-only form is UTC; a date-time form WITHOUT a zone is LOCAL time
749/// (21.4.3.2 via the format's own note), which is why `"2024-01-01T10:20"` is
750/// 15:20Z under `TZ=America/New_York`. The offset used to be dropped entirely:
751/// `"…30.123+01:00"` read the fraction as `123` and ignored the rest, and
752/// `"…00+05:30"` failed to parse. Fields out of range (`T25:00`, `T10:60`, a
753/// month of 13) are NaN, as in V8; a day up to 31 rolls over as V8's does
754/// (`2023-02-29` is March 1).
755fn parse_iso(s: &str) -> Option<f64> {
756    let b = s.as_bytes();
757    let mut i = 0;
758    // `n` ASCII digits at the cursor, as a number.
759    let digits = |i: &mut usize, n: usize| -> Option<i64> {
760        let end = *i + n;
761        let part = b.get(*i..end)?;
762        if !part.iter().all(u8::is_ascii_digit) {
763            return None;
764        }
765        *i = end;
766        std::str::from_utf8(part).ok()?.parse().ok()
767    };
768    let year = match b.first()? {
769        sign @ (b'+' | b'-') => {
770            i = 1;
771            let y = digits(&mut i, 6)?;
772            // `-000000` is the one spelling the format forbids.
773            if *sign == b'-' && y == 0 {
774                return None;
775            }
776            if *sign == b'-' {
777                -y
778            } else {
779                y
780            }
781        }
782        _ => digits(&mut i, 4)?,
783    };
784    let (mut month, mut day) = (1, 1);
785    if b.get(i) == Some(&b'-') {
786        i += 1;
787        month = digits(&mut i, 2)?;
788        if b.get(i) == Some(&b'-') {
789            i += 1;
790            day = digits(&mut i, 2)?;
791        }
792    }
793    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
794        return None;
795    }
796    let date = days_from_civil(year, month - 1, day) as f64 * MS_PER_DAY;
797    // A date-only form, bare or with `Z`, is UTC.
798    match b.get(i) {
799        None => return Some(date),
800        Some(b'Z' | b'z') if i + 1 == b.len() => return Some(date),
801        Some(b'T' | b't' | b' ') => i += 1,
802        Some(_) => return None,
803    }
804    let h = digits(&mut i, 2)?;
805    if b.get(i) != Some(&b':') {
806        return None;
807    }
808    i += 1;
809    let mi = digits(&mut i, 2)?;
810    let (mut sec, mut milli) = (0, 0);
811    if b.get(i) == Some(&b':') {
812        i += 1;
813        sec = digits(&mut i, 2)?;
814        if b.get(i) == Some(&b'.') {
815            i += 1;
816            let start = i;
817            while b.get(i).is_some_and(u8::is_ascii_digit) {
818                i += 1;
819            }
820            if i == start {
821                return None;
822            }
823            // Only the milliseconds are kept; further digits are truncated.
824            let frac = &s[start..i.min(start + 3)];
825            milli = format!("{frac:0<3}").parse().ok()?;
826        }
827    }
828    // `24:00` is the end of the day and nothing past it is.
829    if h > 24 || mi > 59 || sec > 59 || (h == 24 && (mi, sec, milli) != (0, 0, 0)) {
830        return None;
831    }
832    let wall =
833        date + h as f64 * 3_600_000.0 + mi as f64 * 60_000.0 + sec as f64 * 1000.0 + milli as f64;
834    let offset = match b.get(i) {
835        None => return Some(utc_from_local(wall)),
836        Some(b'Z' | b'z') if i + 1 == b.len() => 0.0,
837        Some(sign @ (b'+' | b'-')) => {
838            let sign = if *sign == b'-' { -1.0 } else { 1.0 };
839            i += 1;
840            let oh = digits(&mut i, 2)?;
841            if b.get(i) == Some(&b':') {
842                i += 1;
843            }
844            let om = digits(&mut i, 2)?;
845            if i != b.len() || oh > 23 || om > 59 {
846                return None;
847            }
848            sign * (oh as f64 * 3_600_000.0 + om as f64 * 60_000.0)
849        }
850        Some(_) => return None,
851    };
852    Some(wall - offset)
853}
854
855/// One token of a free-form date string, as V8's `DateStringTokenizer` splits
856/// it: a run of digits (with its length, which decides how an offset reads), a
857/// run of letters, one other character, or white space. A parenthesized
858/// comment — `(Eastern Standard Time)` — is skipped whole.
859#[derive(Clone, Copy, PartialEq)]
860enum Tok<'a> {
861    Num(i64, usize),
862    Word(&'a str),
863    Sym(u8),
864    Space,
865    End,
866}
867
868struct Toks<'a> {
869    s: &'a str,
870    i: usize,
871}
872
873impl<'a> Toks<'a> {
874    fn next(&mut self) -> Tok<'a> {
875        let b = self.s.as_bytes();
876        let Some(&c) = b.get(self.i) else {
877            return Tok::End;
878        };
879        let start = self.i;
880        let run = |i: &mut usize, f: fn(&u8) -> bool| {
881            while b.get(*i).is_some_and(f) {
882                *i += 1;
883            }
884        };
885        if c.is_ascii_digit() {
886            run(&mut self.i, u8::is_ascii_digit);
887            let text = &self.s[start..self.i];
888            // A number too long for any field is still one token; its value
889            // only has to be large enough to fail every range check.
890            return Tok::Num(text.parse().unwrap_or(i64::MAX), text.len());
891        }
892        if c.is_ascii_alphabetic() {
893            run(&mut self.i, u8::is_ascii_alphabetic);
894            return Tok::Word(&self.s[start..self.i]);
895        }
896        if c.is_ascii_whitespace() {
897            run(&mut self.i, u8::is_ascii_whitespace);
898            return Tok::Space;
899        }
900        if c == b'(' {
901            let mut depth = 0;
902            while let Some(&c) = b.get(self.i) {
903                self.i += 1;
904                match c {
905                    b'(' => depth += 1,
906                    b')' => {
907                        depth -= 1;
908                        if depth == 0 {
909                            break;
910                        }
911                    }
912                    _ => {}
913                }
914            }
915            return Tok::Space;
916        }
917        // One character, whole: a non-ASCII one is ignored like any symbol.
918        let len = self.s[start..].chars().next().map_or(1, char::len_utf8);
919        self.i += len;
920        Tok::Sym(if len == 1 { c } else { 0 })
921    }
922
923    fn peek(&self) -> Tok<'a> {
924        Toks {
925            s: self.s,
926            i: self.i,
927        }
928        .next()
929    }
930
931    fn skip(&mut self, sym: u8) -> bool {
932        let taken = self.peek() == Tok::Sym(sym);
933        if taken {
934            self.next();
935        }
936        taken
937    }
938}
939
940/// What a word means to the fallback parser: V8's `KeywordTable`, matched on
941/// the first three letters (so `January` and `Janu` are both January, and `Ja`
942/// is nothing), with the short entries matched whole.
943enum Keyword {
944    Month(i64),
945    AmPm(i64),
946    /// A zone: its offset from UTC in hours.
947    Zone(i64),
948    Other,
949}
950
951fn keyword(word: &str) -> Keyword {
952    let w = word.to_ascii_lowercase();
953    let prefix = &w[..w.len().min(3)];
954    if w.len() >= 3 {
955        const MONTHS: [&str; 12] = [
956            "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
957        ];
958        if let Some(m) = MONTHS.iter().position(|m| *m == prefix) {
959            return Keyword::Month(m as i64 + 1);
960        }
961    }
962    match prefix {
963        "am" if w.len() == 2 => Keyword::AmPm(0),
964        "pm" if w.len() == 2 => Keyword::AmPm(12),
965        "ut" if w.len() == 2 => Keyword::Zone(0),
966        "z" => Keyword::Zone(0),
967        "utc" | "gmt" if w.len() >= 3 => Keyword::Zone(0),
968        "cdt" => Keyword::Zone(-5),
969        "cst" => Keyword::Zone(-6),
970        "edt" => Keyword::Zone(-4),
971        "est" => Keyword::Zone(-5),
972        "mdt" => Keyword::Zone(-6),
973        "mst" => Keyword::Zone(-7),
974        "pdt" => Keyword::Zone(-7),
975        "pst" => Keyword::Zone(-8),
976        _ => Keyword::Other,
977    }
978}
979
980/// The free-form fallback — V8's legacy `DateParser` loop, which is what makes
981/// `new Date("March 7, 2024 10:00")`, `"1/5/2024"`, `"Oct 21, 2015 7:28 PM"`
982/// and the `toString` form `"Thu Mar 07 2024 10:00:00 GMT-0500 (…)"` dates.
983/// Only the IMF-fixdate header form was read before, so every one of those was
984/// an Invalid Date.
985///
986/// Numbers fill the date (`DayComposer`), a number followed by `:` starts the
987/// time (`TimeComposer`), and a zone word or a sign after the time sets the
988/// offset (`TimeZoneComposer`). With no zone the result is LOCAL time.
989fn parse_legacy(s: &str) -> Option<f64> {
990    const NONE: i64 = i64::MIN;
991    let mut t = Toks { s, i: 0 };
992    let (mut day, mut named_month) = (Vec::with_capacity(3), NONE);
993    let (mut time, mut hour_offset) = (Vec::with_capacity(4), NONE);
994    let (mut sign, mut tz_hour, mut tz_min) = (0i64, NONE, NONE);
995    let mut read_number = false;
996    // `TimeComposer::IsExpecting`: the next field the time can take.
997    let expecting = |time: &Vec<i64>, n: i64| match time.len() {
998        1 | 2 => (0..60).contains(&n),
999        3 => (0..1000).contains(&n),
1000        _ => false,
1001    };
1002    loop {
1003        match t.next() {
1004            Tok::End => break,
1005            Tok::Num(n, _) => {
1006                read_number = true;
1007                if t.skip(b':') {
1008                    if t.skip(b':') {
1009                        if !time.is_empty() {
1010                            return None;
1011                        }
1012                        time.extend([n, 0]);
1013                    } else {
1014                        if time.len() >= 4 {
1015                            return None;
1016                        }
1017                        time.push(n);
1018                        t.skip(b'.');
1019                    }
1020                } else if t.peek() == Tok::Sym(b'.') && expecting(&time, n) {
1021                    t.next();
1022                    time.push(n);
1023                    let Tok::Num(ms, len) = t.next() else {
1024                        return None;
1025                    };
1026                    // Milliseconds are the first three digits, scaled.
1027                    let ms = match len {
1028                        1 => ms * 100,
1029                        2 => ms * 10,
1030                        3 => ms,
1031                        _ => t.s[t.i - len..t.i - len + 3].parse().ok()?,
1032                    };
1033                    time.push(ms);
1034                    time.resize(4, 0);
1035                } else if tz_hour != NONE && tz_min == NONE && (0..60).contains(&n) {
1036                    tz_min = n;
1037                } else if expecting(&time, n) {
1038                    time.push(n);
1039                    time.resize(4, 0);
1040                    // The time must end at the end, white space, `Z` or a sign.
1041                    match t.peek() {
1042                        Tok::End | Tok::Space | Tok::Sym(b'+' | b'-') => {}
1043                        Tok::Word(w) if w.eq_ignore_ascii_case("z") => {}
1044                        _ => return None,
1045                    }
1046                } else {
1047                    if day.len() >= 3 {
1048                        return None;
1049                    }
1050                    day.push(n);
1051                    t.skip(b'-');
1052                }
1053            }
1054            Tok::Word(w) => match keyword(w) {
1055                Keyword::AmPm(off) if !time.is_empty() => hour_offset = off,
1056                Keyword::Month(m) => {
1057                    named_month = m;
1058                    t.skip(b'-');
1059                }
1060                Keyword::Zone(h) if read_number => {
1061                    sign = if h < 0 { -1 } else { 1 };
1062                    tz_hour = h.abs();
1063                    tz_min = 0;
1064                }
1065                _ => {
1066                    // A stray word is refused once a number has been read,
1067                    // and must be kept apart from the first number.
1068                    if read_number || matches!(t.peek(), Tok::Num(..)) {
1069                        return None;
1070                    }
1071                }
1072            },
1073            Tok::Sym(c @ (b'+' | b'-'))
1074                if (sign != 0 && tz_hour == 0 && tz_min == 0) || !time.is_empty() =>
1075            {
1076                // An offset, only after a UTC word or a time: `+05`, `+0530`,
1077                // `+05:30`.
1078                sign = if c == b'-' { -1 } else { 1 };
1079                let (n, len) = match t.peek() {
1080                    Tok::Num(n, len) => {
1081                        t.next();
1082                        (n, len)
1083                    }
1084                    _ => (0, 0),
1085                };
1086                read_number = true;
1087                if t.peek() == Tok::Sym(b':') {
1088                    tz_hour = n;
1089                    tz_min = NONE;
1090                } else if len <= 2 {
1091                    tz_hour = n;
1092                    tz_min = 0;
1093                } else if len <= 4 {
1094                    tz_hour = n / 100;
1095                    tz_min = n % 100;
1096                } else {
1097                    return None;
1098                }
1099            }
1100            Tok::Sym(b'+' | b'-' | b')') if read_number => return None,
1101            Tok::Sym(_) | Tok::Space => {}
1102        }
1103    }
1104
1105    // `DayComposer::Write`: the missing components are 1, and which one is the
1106    // year is decided by whether the first can be a day at all.
1107    if day.is_empty() {
1108        return None;
1109    }
1110    day.resize(3, 1);
1111    let is_day = |n: i64| (1..=31).contains(&n);
1112    let (mut year, month, dd) = if named_month == NONE {
1113        if is_day(day[0]) {
1114            (day[2], day[0], day[1])
1115        } else {
1116            (day[0], day[1], day[2])
1117        }
1118    } else if !is_day(day[0]) {
1119        (day[0], named_month, day[1])
1120    } else {
1121        (day[1], named_month, day[0])
1122    };
1123    if (0..=49).contains(&year) {
1124        year += 2000;
1125    } else if (50..=99).contains(&year) {
1126        year += 1900;
1127    }
1128    if !(1..=12).contains(&month) || !is_day(dd) {
1129        return None;
1130    }
1131
1132    // `TimeComposer::Write`: missing fields are 0; AM/PM needs an hour of
1133    // 0-12; hour 24 only as the very end of a day.
1134    time.resize(4, 0);
1135    let (mut h, mi, sec, ms) = (time[0], time[1], time[2], time[3]);
1136    if hour_offset != NONE {
1137        if !(0..=12).contains(&h) {
1138            return None;
1139        }
1140        h = h % 12 + hour_offset;
1141    }
1142    let in_range = (0..24).contains(&h)
1143        && (0..60).contains(&mi)
1144        && (0..60).contains(&sec)
1145        && (0..1000).contains(&ms);
1146    if !in_range && (h, mi, sec, ms) != (24, 0, 0, 0) {
1147        return None;
1148    }
1149
1150    let wall = utc_from_fields(
1151        year as f64,
1152        (month - 1) as f64,
1153        dd as f64,
1154        h as f64,
1155        mi as f64,
1156        sec as f64,
1157        ms as f64,
1158    );
1159    if sign == 0 {
1160        return Some(utc_from_local(wall));
1161    }
1162    let tz_min = if tz_min == NONE { 0 } else { tz_min };
1163    let tz_hour = if tz_hour == NONE { 0 } else { tz_hour };
1164    Some(wall - sign as f64 * (tz_hour * 3_600_000 + tz_min * 60_000) as f64)
1165}
1166
1167/// A Date's `util.inspect` rendering, resolved against an ALREADY-BORROWED host.
1168///
1169/// Node prints a Date as its ISO-8601 form — `console.log(new Date(1))` is
1170/// `1970-01-01T00:00:00.001Z`, not an object literal — and prints the string
1171/// `Invalid Date` for a NaN time value. Without this the inspect walk reached a
1172/// Date through the generic object branch, found its time value in the internal
1173/// `@@ms` slot rather than in an enumerable property, and rendered every Date
1174/// ever logged as `{}`.
1175///
1176/// Takes `&JsHost` rather than calling `with_host` because the inspect walk is
1177/// already inside that borrow; borrowing again aborts the process.
1178pub(crate) fn inspect_with_host(h: &crate::host::JsHost, v: &Value) -> String {
1179    let ms = match h.get(v) {
1180        Some(JsObj::Object(p)) => p.get("@@ms").map(|x| h.to_number(x)).unwrap_or(f64::NAN),
1181        _ => f64::NAN,
1182    };
1183    if ms.is_nan() {
1184        "Invalid Date".into()
1185    } else {
1186        iso_string(ms)
1187    }
1188}