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