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]) — interpreted as UTC.
138        _ => {
139            let n = |i: usize, dflt: f64| {
140                args.get(i)
141                    .map(|v| with_host(|h| h.to_number(v)))
142                    .unwrap_or(dflt)
143            };
144            let mut year = n(0, f64::NAN);
145            // Years 0..99 map to 1900..1999 per the spec.
146            if (0.0..=99.0).contains(&year) {
147                year += 1900.0;
148            }
149            utc_from_fields(
150                year,
151                n(1, 0.0),
152                n(2, 1.0),
153                n(3, 0.0),
154                n(4, 0.0),
155                n(5, 0.0),
156                n(6, 0.0),
157            )
158        }
159    };
160    // TimeClip (21.4.1.31): a value beyond ±8.64e15 ms is not a representable
161    // date and becomes NaN. `new Date(8.64e15 + 1)` used to keep the raw number
162    // and print a real date where node prints `Invalid Date`.
163    Ok(from_ms(time_clip(ms)))
164}
165
166/// `Date.now()` / `Date.parse(str)` / `Date.UTC(...)`.
167pub fn static_call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
168    Some(match method {
169        "now" => Ok(Value::Float(now_ms())),
170        "parse" => Ok(Value::Float(parse_str(&super::arg_str(args, 0)))),
171        "UTC" => {
172            let n = |i: usize, dflt: f64| {
173                args.get(i)
174                    .map(|v| with_host(|h| h.to_number(v)))
175                    .unwrap_or(dflt)
176            };
177            let mut year = n(0, f64::NAN);
178            if (0.0..=99.0).contains(&year) {
179                year += 1900.0;
180            }
181            Ok(Value::Float(utc_from_fields(
182                year,
183                n(1, 0.0),
184                n(2, 1.0),
185                n(3, 0.0),
186                n(4, 0.0),
187                n(5, 0.0),
188                n(6, 0.0),
189            )))
190        }
191        _ => return None,
192    })
193}
194
195/// Date instance methods (all treated as UTC — see the module note).
196pub fn instance_call(recv: &Value, method: &str, _args: &[Value]) -> Result<Value, String> {
197    // Every `set*` argument is `ToNumber`d (21.4.4.x), which runs a user
198    // `valueOf` and can throw from it. The reads below are infallible and do no
199    // `ToPrimitive`, so `d.setFullYear({valueOf: () => 2020})` produced an
200    // Invalid Date.
201    let coerced: Vec<Value>;
202    let _args: &[Value] = if method.starts_with("set") {
203        let mut out = Vec::with_capacity(_args.len());
204        for a in _args {
205            let p = crate::host::to_primitive(a, "number")?;
206            out.push(Value::Float(with_host(|h| h.to_number(&p))));
207        }
208        coerced = out;
209        &coerced
210    } else {
211        _args
212    };
213    let ms = ms_of(recv);
214    let f = |ms: f64| ms; // readability alias for numeric returns
215    Ok(match method {
216        "getTime" | "valueOf" => Value::Float(f(ms)),
217        // Only an explicit `"number"` hint yields the timestamp; `"string"` and
218        // `"default"` both render the date, which is the rule that makes the
219        // default hint behave as `"string"`.
220        "@@toPrimitive" => {
221            let hint = with_host(|h| h.str_of(&_args.first().cloned().unwrap_or(Value::Undef)));
222            if hint == "number" {
223                Value::Float(f(ms))
224            } else {
225                return instance_call(recv, "toString", _args);
226            }
227        }
228        "toISOString" | "toJSON" => {
229            if ms.is_nan() {
230                if method == "toJSON" {
231                    with_host(|h| h.null())
232                } else {
233                    return Err(crate::host::range_error("Invalid time value"));
234                }
235            } else {
236                with_host(|h| h.new_str(iso_string(ms)))
237            }
238        }
239        "toUTCString" | "toGMTString" => with_host(|h| h.new_str(utc_string(ms))),
240        // `toString` (21.4.4.41) is NOT the RFC-7231 header form — that is
241        // `toUTCString`. It is `ToDateString`: the `toDateString` half, a space,
242        // then the `toTimeString` half. This used to answer `toUTCString`, so
243        // `String(date)` and `` `${date}` `` printed
244        // `Thu, 01 Jan 1970 00:00:00 GMT` where node prints
245        // `Thu Jan 01 1970 00:00:00 GMT+0000 (Coordinated Universal Time)`.
246        "toString" => with_host(|h| {
247            h.new_str(if ms.is_nan() {
248                "Invalid Date".into()
249            } else {
250                format!("{} {}", date_string(local_ms(ms)), time_string(ms))
251            })
252        }),
253        "toDateString" => with_host(|h| h.new_str(date_string(local_ms(ms)))),
254        // The three `toLocale*` forms threw `is not a function` — absent
255        // entirely, so `new Date(0).toLocaleString()` failed where node prints
256        // `1/1/1970, 12:00:00 AM`. Rendered in node's default en-US shape
257        // (`M/D/YYYY` and 12-hour `h:mm:ss AM/PM`) at UTC, consistent with the
258        // rest of this module running as if `TZ=UTC`. The `locales`/`options`
259        // arguments are accepted and ignored: without ICU there is nothing to
260        // vary, and answering the default form beats throwing.
261        "toLocaleString" => with_host(|h| {
262            h.new_str(if ms.is_nan() {
263                "Invalid Date".into()
264            } else {
265                format!(
266                    "{}, {}",
267                    locale_date(local_ms(ms)),
268                    locale_time(local_ms(ms))
269                )
270            })
271        }),
272        "toLocaleDateString" => with_host(|h| {
273            h.new_str(if ms.is_nan() {
274                "Invalid Date".into()
275            } else {
276                locale_date(local_ms(ms))
277            })
278        }),
279        "toLocaleTimeString" => with_host(|h| {
280            h.new_str(if ms.is_nan() {
281                "Invalid Date".into()
282            } else {
283                locale_time(local_ms(ms))
284            })
285        }),
286        "getFullYear" => Value::Float(field(local_ms(ms), Field::Year)),
287        "getUTCFullYear" => Value::Float(field(ms, Field::Year)),
288        "getMonth" => Value::Float(field(local_ms(ms), Field::Month)),
289        "getUTCMonth" => Value::Float(field(ms, Field::Month)),
290        "getDate" => Value::Float(field(local_ms(ms), Field::Day)),
291        "getUTCDate" => Value::Float(field(ms, Field::Day)),
292        "getDay" => Value::Float(field(local_ms(ms), Field::Weekday)),
293        "getUTCDay" => Value::Float(field(ms, Field::Weekday)),
294        "getHours" => Value::Float(field(local_ms(ms), Field::Hours)),
295        "getUTCHours" => Value::Float(field(ms, Field::Hours)),
296        "getMinutes" => Value::Float(field(local_ms(ms), Field::Minutes)),
297        "getUTCMinutes" => Value::Float(field(ms, Field::Minutes)),
298        "getSeconds" => Value::Float(field(local_ms(ms), Field::Seconds)),
299        "getUTCSeconds" => Value::Float(field(ms, Field::Seconds)),
300        "getMilliseconds" => Value::Float(field(local_ms(ms), Field::Millis)),
301        "getUTCMilliseconds" => Value::Float(field(ms, Field::Millis)),
302        // 21.4.4.7: minutes WEST of UTC, so the sign is the opposite of the
303        // offset itself — `TZ=America/Detroit` reports 300, not -300.
304        "getTimezoneOffset" => Value::Float(if ms.is_nan() {
305            f64::NAN
306        } else {
307            -zone_offset_ms(ms) / 60_000.0
308        }),
309        "toTimeString" => with_host(|h| h.new_str(time_string(ms))),
310        "setTime" => Value::Float(store_ms(recv, time_clip(super::arg_num(_args, 0)))),
311        // The component setters (21.4.4.20-21.4.4.28). Each takes its own field
312        // plus every LOWER-order one it can reach, defaulting the rest from the
313        // current time value, then rebuilds and TimeClips. `setUTCFullYear` and
314        // friends were absent entirely, so `d.setUTCFullYear(2000)` threw
315        // `is not a function` — a Date could be read but never modified except
316        // wholesale through `setTime`.
317        "setFullYear" => Value::Float(set_fields_local(recv, ms, 0, _args, false)),
318        "setUTCFullYear" => Value::Float(set_fields(recv, ms, 0, _args, false)),
319        "setMonth" => Value::Float(set_fields_local(recv, ms, 1, _args, false)),
320        "setUTCMonth" => Value::Float(set_fields(recv, ms, 1, _args, false)),
321        "setDate" => Value::Float(set_fields_local(recv, ms, 2, _args, false)),
322        "setUTCDate" => Value::Float(set_fields(recv, ms, 2, _args, false)),
323        "setHours" => Value::Float(set_fields_local(recv, ms, 3, _args, false)),
324        "setUTCHours" => Value::Float(set_fields(recv, ms, 3, _args, false)),
325        "setMinutes" => Value::Float(set_fields_local(recv, ms, 4, _args, false)),
326        "setUTCMinutes" => Value::Float(set_fields(recv, ms, 4, _args, false)),
327        "setSeconds" => Value::Float(set_fields_local(recv, ms, 5, _args, false)),
328        "setUTCSeconds" => Value::Float(set_fields(recv, ms, 5, _args, false)),
329        "setMilliseconds" => Value::Float(set_fields_local(recv, ms, 6, _args, false)),
330        "setUTCMilliseconds" => Value::Float(set_fields(recv, ms, 6, _args, false)),
331        // Annex B B.2.3.3 / B.2.3.4 — offset-from-1900 year accessors kept for
332        // legacy code. `setYear` maps 0..99 onto 1900..1999, which is the only
333        // way it differs from `setFullYear`.
334        // Annex B's pair is LOCAL, like `getFullYear`/`setFullYear`.
335        "getYear" => Value::Float(if ms.is_nan() {
336            f64::NAN
337        } else {
338            field(local_ms(ms), Field::Year) - 1900.0
339        }),
340        "setYear" => Value::Float(set_fields_local(recv, ms, 0, _args, true)),
341        _ => {
342            return Err(crate::host::type_error(&format!(
343                "date.{method} is not a function"
344            )))
345        }
346    })
347}
348
349// ── civil-calendar conversions (days-from-epoch ⇄ Y/M/D), UTC only ────────────
350
351enum Field {
352    Year,
353    Month,
354    Day,
355    Weekday,
356    Hours,
357    Minutes,
358    Seconds,
359    Millis,
360}
361
362/// Split a time value into (days-from-epoch, ms-within-day), flooring toward -∞
363/// so negative (pre-1970) times decompose correctly.
364fn split_day(ms: f64) -> (i64, i64) {
365    let day = (ms / MS_PER_DAY).floor();
366    let rem = ms - day * MS_PER_DAY;
367    (day as i64, rem as i64)
368}
369
370/// Convert a days-from-epoch count to (year, month 0-11, day 1-31) using
371/// Howard Hinnant's civil_from_days algorithm.
372fn civil_from_days(z: i64) -> (i64, i64, i64) {
373    let z = z + 719_468;
374    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
375    let doe = z - era * 146_097; // [0, 146096]
376    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
377    let y = yoe + era * 400;
378    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
379    let mp = (5 * doy + 2) / 153; // [0, 11]
380    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
381    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
382    (if m <= 2 { y + 1 } else { y }, m - 1, d)
383}
384
385/// Inverse: (year, month 0-11, day) → days from epoch.
386fn days_from_civil(y: i64, m0: i64, d: i64) -> i64 {
387    let m = m0 + 1;
388    let y = if m <= 2 { y - 1 } else { y };
389    let era = if y >= 0 { y } else { y - 399 } / 400;
390    let yoe = y - era * 400;
391    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
392    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
393    era * 146_097 + doe - 719_468
394}
395
396fn field(ms: f64, which: Field) -> f64 {
397    if ms.is_nan() {
398        return f64::NAN;
399    }
400    let (day, rem) = split_day(ms);
401    let (y, mo, d) = civil_from_days(day);
402    match which {
403        Field::Year => y as f64,
404        Field::Month => mo as f64,
405        Field::Day => d as f64,
406        // Weekday: 1970-01-01 (day 0) was a Thursday (4).
407        Field::Weekday => (((day % 7) + 4 + 7) % 7) as f64,
408        Field::Hours => (rem / 3_600_000) as f64,
409        Field::Minutes => (rem / 60_000 % 60) as f64,
410        Field::Seconds => (rem / 1000 % 60) as f64,
411        Field::Millis => (rem % 1000) as f64,
412    }
413}
414
415/// Assemble a UTC time value from broken-down fields (with month/day overflow
416/// normalized the way JS does, e.g. month 12 rolls into the next year).
417fn utc_from_fields(y: f64, mo: f64, d: f64, h: f64, mi: f64, s: f64, ms: f64) -> f64 {
418    if [y, mo, d, h, mi, s, ms].iter().any(|v| v.is_nan()) {
419        return f64::NAN;
420    }
421    // Normalize month into 0..11, carrying into the year.
422    let total_months = y as i64 * 12 + mo as i64;
423    let year = total_months.div_euclid(12);
424    let month = total_months.rem_euclid(12);
425    let days = days_from_civil(year, month, d as i64);
426    days as f64 * MS_PER_DAY + h * 3_600_000.0 + mi * 60_000.0 + s * 1000.0 + ms
427}
428
429/// `Wed, 21 Oct 2015 07:28:00 GMT` — the RFC-7231 IMF-fixdate HTTP header form.
430/// The zone offset in MILLISECONDS east of UTC that applies at `ms`, from the
431/// C library's `localtime_r` — which reads `TZ` exactly as node does and is
432/// DST-aware per timestamp rather than per zone.
433///
434/// Everything local used to be UTC: `getTimezoneOffset()` answered 0, each
435/// local getter shared its arm with the `getUTC*` one, and `toString` rendered
436/// the UTC wall clock. Under `TZ=America/Detroit` that made
437/// `new Date(0).getMonth()` 0 where node says 11. The parity harness pins
438/// `TZ=UTC` for both sides, which is why no record ever caught it.
439#[cfg(unix)]
440fn zone_offset_ms(ms: f64) -> f64 {
441    if !ms.is_finite() {
442        return 0.0;
443    }
444    // `localtime_r` takes SECONDS; flooring keeps a pre-epoch timestamp in the
445    // right second rather than rounding it toward zero.
446    let secs = (ms / 1000.0).floor() as i64;
447    let t = secs as libc::time_t;
448    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
449    // SAFETY: `localtime_r` writes into the caller's `tm` and reads only `t`.
450    let ok = unsafe { !libc::localtime_r(&t, &mut tm).is_null() };
451    if !ok {
452        return 0.0;
453    }
454    tm.tm_gmtoff as f64 * 1000.0
455}
456
457#[cfg(not(unix))]
458fn zone_offset_ms(_ms: f64) -> f64 {
459    0.0
460}
461
462/// The local wall-clock time value for `ms` — what every local getter reads its
463/// fields out of.
464fn local_ms(ms: f64) -> f64 {
465    ms + zone_offset_ms(ms)
466}
467
468/// The inverse: a local wall-clock time value back to a timestamp.
469///
470/// The offset depends on the instant, so one lookup is not enough near a DST
471/// transition. Two candidates are built — using the offset at the naive guess
472/// and at the corrected one — and the one that reads BACK as the requested
473/// local time wins. A spring-forward GAP has no such candidate, because the
474/// wall clock never showed that time; 21.4.1.26 leaves the choice to the
475/// implementation and V8 takes the offset from BEFORE the transition, which
476/// pushes the result past it: local 02:30 on a spring-forward day is 03:30.
477fn utc_from_local(local: f64) -> f64 {
478    if !local.is_finite() {
479        return local;
480    }
481    let off_naive = zone_offset_ms(local);
482    let cand_a = local - off_naive;
483    let off_corrected = zone_offset_ms(cand_a);
484    if off_corrected == off_naive {
485        return cand_a;
486    }
487    let cand_b = local - off_corrected;
488    if local_ms(cand_b) == local {
489        return cand_b;
490    }
491    if local_ms(cand_a) == local {
492        return cand_a;
493    }
494    local - off_naive.min(off_corrected)
495}
496
497fn utc_string(ms: f64) -> String {
498    if ms.is_nan() {
499        return "Invalid Date".into();
500    }
501    let (day, _) = split_day(ms);
502    let (y, mo, d) = civil_from_days(day);
503    let wd = (((day % 7) + 4 + 7) % 7) as usize;
504    format!(
505        "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
506        DAYS[wd],
507        d,
508        MONTHS[mo as usize],
509        y,
510        field(ms, Field::Hours) as i64,
511        field(ms, Field::Minutes) as i64,
512        field(ms, Field::Seconds) as i64,
513    )
514}
515
516/// `00:00:00 GMT+0000 (Coordinated Universal Time)` — the `toTimeString` form
517/// (21.4.4.42 TimeString + TimeZoneString). The clock is LOCAL and the offset
518/// is the zone's real one; both were fixed at UTC before.
519///
520/// The parenthetical is the zone's LONG name, which node takes from ICU. There
521/// is none here, so only UTC — the one name that is not data — is spelled out
522/// and every other zone reports the abbreviation `localtime_r` supplies
523/// (`GMT-0500 (EST)` where node writes `(Eastern Standard Time)`). Recorded in
524/// BUGS.md.
525fn time_string(ms: f64) -> String {
526    if ms.is_nan() {
527        return "Invalid Date".into();
528    }
529    let local = local_ms(ms);
530    let off_min = (zone_offset_ms(ms) / 60_000.0) as i64;
531    let sign = if off_min < 0 { '-' } else { '+' };
532    let abs = off_min.abs();
533    format!(
534        "{:02}:{:02}:{:02} GMT{}{:02}{:02} ({})",
535        field(local, Field::Hours) as i64,
536        field(local, Field::Minutes) as i64,
537        field(local, Field::Seconds) as i64,
538        sign,
539        abs / 60,
540        abs % 60,
541        zone_name(ms),
542    )
543}
544
545/// The zone's display name for `toString`. UTC is spelled out the way node
546/// does; anything else falls back to the abbreviation.
547#[cfg(unix)]
548fn zone_name(ms: f64) -> String {
549    if zone_offset_ms(ms) == 0.0 {
550        return "Coordinated Universal Time".into();
551    }
552    let secs = (ms / 1000.0).floor() as i64;
553    let t = secs as libc::time_t;
554    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
555    // SAFETY: as in `zone_offset_ms`.
556    if unsafe { libc::localtime_r(&t, &mut tm).is_null() } || tm.tm_zone.is_null() {
557        return "Coordinated Universal Time".into();
558    }
559    // SAFETY: `tm_zone` points at a static zone-name string owned by libc.
560    let z = unsafe { std::ffi::CStr::from_ptr(tm.tm_zone) };
561    z.to_string_lossy().into_owned()
562}
563
564#[cfg(not(unix))]
565fn zone_name(_ms: f64) -> String {
566    "Coordinated Universal Time".into()
567}
568
569/// TimeClip (21.4.1.31): a time value more than 8.64e15 ms from the epoch is not
570/// representable and becomes NaN; anything else truncates toward zero.
571///
572/// Without this a `new Date(8.64e15 + 1)` kept the out-of-range value and
573/// printed a real date (`Sat, 13 Sep 275760 …`) where node prints
574/// `Invalid Date`, so the boundary every date-range check relies on was absent.
575fn time_clip(ms: f64) -> f64 {
576    if !ms.is_finite() || ms.abs() > 8.64e15 {
577        return f64::NAN;
578    }
579    ms.trunc()
580}
581
582/// Write a time value into the receiver's hidden `@@ms` slot, returning it (as
583/// every mutator does).
584fn store_ms(recv: &Value, ms: f64) -> f64 {
585    with_host(|h| {
586        if let Some(JsObj::Object(p)) = h.get_mut(recv) {
587            p.insert("@@ms".into(), Value::Float(ms));
588        }
589    });
590    ms
591}
592
593/// The shared body of every component setter.
594///
595/// `start` indexes the field the setter names within
596/// `[year, month, date, hours, minutes, seconds, ms]`; the setter consumes that
597/// field and every LOWER-order one in its own group (date fields 0..2, time
598/// fields 3..6), defaulting anything not supplied from the current time value.
599///
600/// `legacy_year` applies Annex B `setYear`'s 0..99 → 1900..1999 mapping.
601///
602/// NaN handling follows the spec's split: `setFullYear` on an invalid date
603/// treats the time value as +0 and so can REVIVE it (21.4.4.21 step 2), while
604/// every other setter leaves an invalid date invalid.
605/// `set_fields` on the LOCAL wall clock: read the current fields in local time,
606/// replace the ones given, then convert the result back to a timestamp.
607fn set_fields_local(recv: &Value, ms: f64, start: usize, args: &[Value], legacy_year: bool) -> f64 {
608    if ms.is_nan() && start != 0 {
609        return store_ms(recv, f64::NAN);
610    }
611    let local = set_fields_value(local_ms(ms), start, args, legacy_year);
612    store_ms(recv, time_clip(utc_from_local(local)))
613}
614
615fn set_fields(recv: &Value, ms: f64, start: usize, args: &[Value], legacy_year: bool) -> f64 {
616    if ms.is_nan() && start != 0 {
617        return store_ms(recv, f64::NAN);
618    }
619    store_ms(
620        recv,
621        time_clip(set_fields_value(ms, start, args, legacy_year)),
622    )
623}
624
625/// The field replacement itself, on whatever time value it is handed — the
626/// local wall clock for a `setHours`, the timestamp for a `setUTCHours`. Split
627/// out so the two differ only in what they pass in and what they do with the
628/// result.
629fn set_fields_value(ms: f64, start: usize, args: &[Value], legacy_year: bool) -> f64 {
630    let base = if ms.is_nan() {
631        0.0 // setFullYear/setYear on an Invalid Date starts from the epoch.
632    } else {
633        ms
634    };
635    let mut f = [
636        field(base, Field::Year),
637        field(base, Field::Month),
638        field(base, Field::Day),
639        field(base, Field::Hours),
640        field(base, Field::Minutes),
641        field(base, Field::Seconds),
642        field(base, Field::Millis),
643    ];
644    // A date setter reaches at most field 2; a time setter at most field 6.
645    let end = if start < 3 { 3 } else { 7 };
646    for (i, slot) in f.iter_mut().enumerate().take(end).skip(start) {
647        match args.get(i - start) {
648            Some(v) => *slot = with_host(|h| h.to_number(v)).trunc(),
649            None => break,
650        }
651    }
652    if legacy_year && (0.0..=99.0).contains(&f[0]) {
653        f[0] += 1900.0;
654    }
655    utc_from_fields(f[0], f[1], f[2], f[3], f[4], f[5], f[6])
656}
657
658/// `Wed Oct 21 2015` — the `toDateString` form.
659fn date_string(ms: f64) -> String {
660    if ms.is_nan() {
661        return "Invalid Date".into();
662    }
663    let (day, _) = split_day(ms);
664    let (y, mo, d) = civil_from_days(day);
665    let wd = (((day % 7) + 4 + 7) % 7) as usize;
666    format!("{} {} {:02} {:04}", DAYS[wd], MONTHS[mo as usize], d, y)
667}
668
669/// `1/2/2020` — the `toLocaleDateString` default (en-US `M/D/YYYY`, no padding).
670fn locale_date(ms: f64) -> String {
671    let (day, _) = split_day(ms);
672    let (y, mo, d) = civil_from_days(day);
673    format!("{}/{}/{:04}", mo + 1, d, y)
674}
675
676/// `3:04:05 PM` — the `toLocaleTimeString` default (en-US 12-hour). Hour 0 and
677/// hour 12 both render as `12`, which is why this is not `h % 12`.
678fn locale_time(ms: f64) -> String {
679    let h24 = field(ms, Field::Hours) as i64;
680    let (h12, meridiem) = match h24 {
681        0 => (12, "AM"),
682        1..=11 => (h24, "AM"),
683        12 => (12, "PM"),
684        _ => (h24 - 12, "PM"),
685    };
686    format!(
687        "{}:{:02}:{:02} {}",
688        h12,
689        field(ms, Field::Minutes) as i64,
690        field(ms, Field::Seconds) as i64,
691        meridiem
692    )
693}
694
695/// The year field of an ISO-8601 date (21.4.4.36 `Date.prototype.toISOString`).
696///
697/// Years 0..=9999 are four digits; anything outside that range uses the EXPANDED
698/// form — an explicit sign and exactly six digits, `+275760` / `-000001`. A bare
699/// `{:04}` gets both wrong, since Rust counts the sign inside the width (`-1`
700/// formats as `-001`) and never emits `+`.
701fn iso_year(y: i64) -> String {
702    if (0..=9999).contains(&y) {
703        return format!("{y:04}");
704    }
705    let sign = if y < 0 { '-' } else { '+' };
706    format!("{sign}{:06}", y.abs())
707}
708
709/// `2015-10-21T07:28:00.000Z` — the ISO-8601 / `toISOString` form.
710fn iso_string(ms: f64) -> String {
711    let (day, _) = split_day(ms);
712    let (y, mo, d) = civil_from_days(day);
713    format!(
714        "{}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
715        iso_year(y),
716        mo + 1,
717        d,
718        field(ms, Field::Hours) as i64,
719        field(ms, Field::Minutes) as i64,
720        field(ms, Field::Seconds) as i64,
721        field(ms, Field::Millis) as i64,
722    )
723}
724
725/// Parse a date string. Supports the two forms HTTP code produces: ISO-8601
726/// (`2015-10-21T07:28:00.000Z` / date-only `2015-10-21`) and the RFC-1123 /
727/// IMF-fixdate header form (`Wed, 21 Oct 2015 07:28:00 GMT`). Returns NaN on any
728/// input that does not match — the JS "Invalid Date" contract.
729fn parse_str(s: &str) -> f64 {
730    let s = s.trim();
731    if let Some(ms) = parse_iso(s) {
732        return ms;
733    }
734    if let Some(ms) = parse_rfc1123(s) {
735        return ms;
736    }
737    f64::NAN
738}
739
740/// ISO-8601: `YYYY-MM-DD[THH:MM:SS[.sss]][Z]` (a bare date is treated as UTC
741/// midnight, matching modern V8).
742fn parse_iso(s: &str) -> Option<f64> {
743    let (date, time) = match s.split_once(['T', ' ']) {
744        Some((d, t)) => (d, Some(t)),
745        None => (s, None),
746    };
747    let dp: Vec<&str> = date.split('-').collect();
748    if dp.len() != 3 {
749        return None;
750    }
751    let y: i64 = dp[0].parse().ok()?;
752    let mo: i64 = dp[1].parse().ok()?;
753    let d: i64 = dp[2].parse().ok()?;
754    if !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
755        return None;
756    }
757    let (mut h, mut mi, mut sec, mut milli) = (0i64, 0i64, 0i64, 0i64);
758    if let Some(t) = time {
759        let t = t.trim_end_matches('Z');
760        let (hms, frac) = match t.split_once('.') {
761            Some((a, b)) => (a, Some(b)),
762            None => (t, None),
763        };
764        let tp: Vec<&str> = hms.split(':').collect();
765        if tp.is_empty() {
766            return None;
767        }
768        h = tp[0].parse().ok()?;
769        mi = tp.get(1).map(|v| v.parse().ok()).unwrap_or(Some(0))?;
770        sec = tp.get(2).map(|v| v.parse().ok()).unwrap_or(Some(0))?;
771        if let Some(fr) = frac {
772            let fr: String = fr.chars().take(3).collect();
773            let padded = format!("{fr:0<3}");
774            milli = padded.parse().ok()?;
775        }
776    }
777    let days = days_from_civil(y, mo - 1, d);
778    Some(
779        days as f64 * MS_PER_DAY
780            + h as f64 * 3_600_000.0
781            + mi as f64 * 60_000.0
782            + sec as f64 * 1000.0
783            + milli as f64,
784    )
785}
786
787/// RFC-1123 / IMF-fixdate: `Wed, 21 Oct 2015 07:28:00 GMT`.
788fn parse_rfc1123(s: &str) -> Option<f64> {
789    // Drop an optional leading weekday token (`Wed,`).
790    let s = match s.split_once(", ") {
791        Some((_, rest)) => rest,
792        None => s,
793    };
794    let parts: Vec<&str> = s.split_whitespace().collect();
795    if parts.len() < 5 {
796        return None;
797    }
798    let d: i64 = parts[0].parse().ok()?;
799    let mo = MONTHS.iter().position(|m| *m == parts[1])? as i64;
800    let y: i64 = parts[2].parse().ok()?;
801    let tp: Vec<&str> = parts[3].split(':').collect();
802    if tp.len() < 2 {
803        return None;
804    }
805    let h: i64 = tp[0].parse().ok()?;
806    let mi: i64 = tp[1].parse().ok()?;
807    let sec: i64 = tp.get(2).map(|v| v.parse().ok()).unwrap_or(Some(0))?;
808    let days = days_from_civil(y, mo, d);
809    Some(
810        days as f64 * MS_PER_DAY
811            + h as f64 * 3_600_000.0
812            + mi as f64 * 60_000.0
813            + sec as f64 * 1000.0,
814    )
815}
816
817/// A Date's `util.inspect` rendering, resolved against an ALREADY-BORROWED host.
818///
819/// Node prints a Date as its ISO-8601 form — `console.log(new Date(1))` is
820/// `1970-01-01T00:00:00.001Z`, not an object literal — and prints the string
821/// `Invalid Date` for a NaN time value. Without this the inspect walk reached a
822/// Date through the generic object branch, found its time value in the internal
823/// `@@ms` slot rather than in an enumerable property, and rendered every Date
824/// ever logged as `{}`.
825///
826/// Takes `&JsHost` rather than calling `with_host` because the inspect walk is
827/// already inside that borrow; borrowing again aborts the process.
828pub(crate) fn inspect_with_host(h: &crate::host::JsHost, v: &Value) -> String {
829    let ms = match h.get(v) {
830        Some(JsObj::Object(p)) => p.get("@@ms").map(|x| h.to_number(x)).unwrap_or(f64::NAN),
831        _ => f64::NAN,
832    };
833    if ms.is_nan() {
834        "Invalid Date".into()
835    } else {
836        iso_string(ms)
837    }
838}