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//! Only the UTC-based surface HTTP libraries actually exercise is implemented —
6//! `getTime`/`valueOf`, `toISOString`/`toUTCString`/`toString`, the UTC field
7//! getters, plus the statics `Date.now`/`Date.parse`/`Date.UTC`. Local-timezone
8//! getters alias the UTC ones (node-js runs as if TZ=UTC), which is the correct
9//! answer for the machine-readable date headers express/send/fresh produce.
10
11use crate::host::{with_host, JsObj};
12use fusevm::Value;
13use indexmap::IndexMap;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16pub const STATIC_METHODS: &[&str] = &["now", "parse", "UTC"];
17
18const MS_PER_DAY: f64 = 86_400_000.0;
19const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
20const MONTHS: [&str; 12] = [
21    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
22];
23
24/// Milliseconds since the Unix epoch, right now.
25fn now_ms() -> f64 {
26    SystemTime::now()
27        .duration_since(UNIX_EPOCH)
28        .map(|d| d.as_millis() as f64)
29        .unwrap_or(0.0)
30}
31
32/// Build a Date value carrying `ms` (NaN → an "Invalid Date").
33fn from_ms(ms: f64) -> Value {
34    with_host(|h| {
35        let mut m = IndexMap::new();
36        m.insert("@@native".into(), h.new_str("Date"));
37        m.insert("@@ms".into(), Value::Float(ms));
38        h.new_object(m)
39    })
40}
41
42/// The stored time value of a Date instance (NaN if not a Date).
43fn ms_of(recv: &Value) -> f64 {
44    with_host(|h| match h.get(recv) {
45        Some(JsObj::Object(p)) => p.get("@@ms").map(|v| h.to_number(v)).unwrap_or(f64::NAN),
46        _ => f64::NAN,
47    })
48}
49
50/// `new Date(...)`.
51pub fn construct(args: &[Value]) -> Result<Value, String> {
52    let ms = match args.len() {
53        0 => now_ms(),
54        1 => {
55            let a = &args[0];
56            // A string argument is parsed; anything else is coerced to a number
57            // (milliseconds). Another Date coerces via its time value.
58            if let Value::Str(_) = a {
59                parse_str(&with_host(|h| h.str_of(a)))
60            } else if with_host(|h| matches!(h.get(a), Some(JsObj::Str(_)))) {
61                parse_str(&with_host(|h| h.str_of(a)))
62            } else if super::native_tag(a).as_deref() == Some("Date") {
63                ms_of(a)
64            } else {
65                with_host(|h| h.to_number(a))
66            }
67        }
68        // (year, month[, day, hours, minutes, seconds, ms]) — interpreted as UTC.
69        _ => {
70            let n = |i: usize, dflt: f64| {
71                args.get(i)
72                    .map(|v| with_host(|h| h.to_number(v)))
73                    .unwrap_or(dflt)
74            };
75            let mut year = n(0, f64::NAN);
76            // Years 0..99 map to 1900..1999 per the spec.
77            if (0.0..=99.0).contains(&year) {
78                year += 1900.0;
79            }
80            utc_from_fields(
81                year,
82                n(1, 0.0),
83                n(2, 1.0),
84                n(3, 0.0),
85                n(4, 0.0),
86                n(5, 0.0),
87                n(6, 0.0),
88            )
89        }
90    };
91    Ok(from_ms(ms))
92}
93
94/// `Date.now()` / `Date.parse(str)` / `Date.UTC(...)`.
95pub fn static_call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
96    Some(match method {
97        "now" => Ok(Value::Float(now_ms())),
98        "parse" => Ok(Value::Float(parse_str(&super::arg_str(args, 0)))),
99        "UTC" => {
100            let n = |i: usize, dflt: f64| {
101                args.get(i)
102                    .map(|v| with_host(|h| h.to_number(v)))
103                    .unwrap_or(dflt)
104            };
105            let mut year = n(0, f64::NAN);
106            if (0.0..=99.0).contains(&year) {
107                year += 1900.0;
108            }
109            Ok(Value::Float(utc_from_fields(
110                year,
111                n(1, 0.0),
112                n(2, 1.0),
113                n(3, 0.0),
114                n(4, 0.0),
115                n(5, 0.0),
116                n(6, 0.0),
117            )))
118        }
119        _ => return None,
120    })
121}
122
123/// Date instance methods (all treated as UTC — see the module note).
124pub fn instance_call(recv: &Value, method: &str, _args: &[Value]) -> Result<Value, String> {
125    let ms = ms_of(recv);
126    let f = |ms: f64| ms; // readability alias for numeric returns
127    Ok(match method {
128        "getTime" | "valueOf" => Value::Float(f(ms)),
129        "toISOString" | "toJSON" => {
130            if ms.is_nan() {
131                if method == "toJSON" {
132                    with_host(|h| h.null())
133                } else {
134                    return Err(crate::host::range_error("Invalid time value"));
135                }
136            } else {
137                with_host(|h| h.new_str(iso_string(ms)))
138            }
139        }
140        "toUTCString" | "toGMTString" => with_host(|h| h.new_str(utc_string(ms))),
141        "toString" => with_host(|h| {
142            h.new_str(if ms.is_nan() {
143                "Invalid Date".into()
144            } else {
145                utc_string(ms)
146            })
147        }),
148        "toDateString" => with_host(|h| h.new_str(date_string(ms))),
149        "getFullYear" | "getUTCFullYear" => Value::Float(field(ms, Field::Year)),
150        "getMonth" | "getUTCMonth" => Value::Float(field(ms, Field::Month)),
151        "getDate" | "getUTCDate" => Value::Float(field(ms, Field::Day)),
152        "getDay" | "getUTCDay" => Value::Float(field(ms, Field::Weekday)),
153        "getHours" | "getUTCHours" => Value::Float(field(ms, Field::Hours)),
154        "getMinutes" | "getUTCMinutes" => Value::Float(field(ms, Field::Minutes)),
155        "getSeconds" | "getUTCSeconds" => Value::Float(field(ms, Field::Seconds)),
156        "getMilliseconds" | "getUTCMilliseconds" => Value::Float(field(ms, Field::Millis)),
157        "getTimezoneOffset" => Value::Float(0.0), // node-js runs as UTC
158        "setTime" => {
159            let new_ms = super::arg_num(_args, 0);
160            with_host(|h| {
161                if let Some(JsObj::Object(p)) = h.get_mut(recv) {
162                    p.insert("@@ms".into(), Value::Float(new_ms));
163                }
164            });
165            Value::Float(new_ms)
166        }
167        _ => {
168            return Err(crate::host::type_error(&format!(
169                "date.{method} is not a function"
170            )))
171        }
172    })
173}
174
175// ── civil-calendar conversions (days-from-epoch ⇄ Y/M/D), UTC only ────────────
176
177enum Field {
178    Year,
179    Month,
180    Day,
181    Weekday,
182    Hours,
183    Minutes,
184    Seconds,
185    Millis,
186}
187
188/// Split a time value into (days-from-epoch, ms-within-day), flooring toward -∞
189/// so negative (pre-1970) times decompose correctly.
190fn split_day(ms: f64) -> (i64, i64) {
191    let day = (ms / MS_PER_DAY).floor();
192    let rem = ms - day * MS_PER_DAY;
193    (day as i64, rem as i64)
194}
195
196/// Convert a days-from-epoch count to (year, month 0-11, day 1-31) using
197/// Howard Hinnant's civil_from_days algorithm.
198fn civil_from_days(z: i64) -> (i64, i64, i64) {
199    let z = z + 719_468;
200    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
201    let doe = z - era * 146_097; // [0, 146096]
202    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
203    let y = yoe + era * 400;
204    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
205    let mp = (5 * doy + 2) / 153; // [0, 11]
206    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
207    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
208    (if m <= 2 { y + 1 } else { y }, m - 1, d)
209}
210
211/// Inverse: (year, month 0-11, day) → days from epoch.
212fn days_from_civil(y: i64, m0: i64, d: i64) -> i64 {
213    let m = m0 + 1;
214    let y = if m <= 2 { y - 1 } else { y };
215    let era = if y >= 0 { y } else { y - 399 } / 400;
216    let yoe = y - era * 400;
217    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
218    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
219    era * 146_097 + doe - 719_468
220}
221
222fn field(ms: f64, which: Field) -> f64 {
223    if ms.is_nan() {
224        return f64::NAN;
225    }
226    let (day, rem) = split_day(ms);
227    let (y, mo, d) = civil_from_days(day);
228    match which {
229        Field::Year => y as f64,
230        Field::Month => mo as f64,
231        Field::Day => d as f64,
232        // Weekday: 1970-01-01 (day 0) was a Thursday (4).
233        Field::Weekday => (((day % 7) + 4 + 7) % 7) as f64,
234        Field::Hours => (rem / 3_600_000) as f64,
235        Field::Minutes => (rem / 60_000 % 60) as f64,
236        Field::Seconds => (rem / 1000 % 60) as f64,
237        Field::Millis => (rem % 1000) as f64,
238    }
239}
240
241/// Assemble a UTC time value from broken-down fields (with month/day overflow
242/// normalized the way JS does, e.g. month 12 rolls into the next year).
243fn utc_from_fields(y: f64, mo: f64, d: f64, h: f64, mi: f64, s: f64, ms: f64) -> f64 {
244    if [y, mo, d, h, mi, s, ms].iter().any(|v| v.is_nan()) {
245        return f64::NAN;
246    }
247    // Normalize month into 0..11, carrying into the year.
248    let total_months = y as i64 * 12 + mo as i64;
249    let year = total_months.div_euclid(12);
250    let month = total_months.rem_euclid(12);
251    let days = days_from_civil(year, month, d as i64);
252    days as f64 * MS_PER_DAY + h * 3_600_000.0 + mi * 60_000.0 + s * 1000.0 + ms
253}
254
255/// `Wed, 21 Oct 2015 07:28:00 GMT` — the RFC-7231 IMF-fixdate HTTP header form.
256fn utc_string(ms: f64) -> String {
257    if ms.is_nan() {
258        return "Invalid Date".into();
259    }
260    let (day, _) = split_day(ms);
261    let (y, mo, d) = civil_from_days(day);
262    let wd = (((day % 7) + 4 + 7) % 7) as usize;
263    format!(
264        "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
265        DAYS[wd],
266        d,
267        MONTHS[mo as usize],
268        y,
269        field(ms, Field::Hours) as i64,
270        field(ms, Field::Minutes) as i64,
271        field(ms, Field::Seconds) as i64,
272    )
273}
274
275/// `Wed Oct 21 2015` — the `toDateString` form.
276fn date_string(ms: f64) -> String {
277    if ms.is_nan() {
278        return "Invalid Date".into();
279    }
280    let (day, _) = split_day(ms);
281    let (y, mo, d) = civil_from_days(day);
282    let wd = (((day % 7) + 4 + 7) % 7) as usize;
283    format!("{} {} {:02} {:04}", DAYS[wd], MONTHS[mo as usize], d, y)
284}
285
286/// `2015-10-21T07:28:00.000Z` — the ISO-8601 / `toISOString` form.
287fn iso_string(ms: f64) -> String {
288    let (day, _) = split_day(ms);
289    let (y, mo, d) = civil_from_days(day);
290    format!(
291        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
292        y,
293        mo + 1,
294        d,
295        field(ms, Field::Hours) as i64,
296        field(ms, Field::Minutes) as i64,
297        field(ms, Field::Seconds) as i64,
298        field(ms, Field::Millis) as i64,
299    )
300}
301
302/// Parse a date string. Supports the two forms HTTP code produces: ISO-8601
303/// (`2015-10-21T07:28:00.000Z` / date-only `2015-10-21`) and the RFC-1123 /
304/// IMF-fixdate header form (`Wed, 21 Oct 2015 07:28:00 GMT`). Returns NaN on any
305/// input that does not match — the JS "Invalid Date" contract.
306fn parse_str(s: &str) -> f64 {
307    let s = s.trim();
308    if let Some(ms) = parse_iso(s) {
309        return ms;
310    }
311    if let Some(ms) = parse_rfc1123(s) {
312        return ms;
313    }
314    f64::NAN
315}
316
317/// ISO-8601: `YYYY-MM-DD[THH:MM:SS[.sss]][Z]` (a bare date is treated as UTC
318/// midnight, matching modern V8).
319fn parse_iso(s: &str) -> Option<f64> {
320    let (date, time) = match s.split_once(['T', ' ']) {
321        Some((d, t)) => (d, Some(t)),
322        None => (s, None),
323    };
324    let dp: Vec<&str> = date.split('-').collect();
325    if dp.len() != 3 {
326        return None;
327    }
328    let y: i64 = dp[0].parse().ok()?;
329    let mo: i64 = dp[1].parse().ok()?;
330    let d: i64 = dp[2].parse().ok()?;
331    if !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
332        return None;
333    }
334    let (mut h, mut mi, mut sec, mut milli) = (0i64, 0i64, 0i64, 0i64);
335    if let Some(t) = time {
336        let t = t.trim_end_matches('Z');
337        let (hms, frac) = match t.split_once('.') {
338            Some((a, b)) => (a, Some(b)),
339            None => (t, None),
340        };
341        let tp: Vec<&str> = hms.split(':').collect();
342        if tp.is_empty() {
343            return None;
344        }
345        h = tp[0].parse().ok()?;
346        mi = tp.get(1).map(|v| v.parse().ok()).unwrap_or(Some(0))?;
347        sec = tp.get(2).map(|v| v.parse().ok()).unwrap_or(Some(0))?;
348        if let Some(fr) = frac {
349            let fr: String = fr.chars().take(3).collect();
350            let padded = format!("{fr:0<3}");
351            milli = padded.parse().ok()?;
352        }
353    }
354    let days = days_from_civil(y, mo - 1, d);
355    Some(
356        days as f64 * MS_PER_DAY
357            + h as f64 * 3_600_000.0
358            + mi as f64 * 60_000.0
359            + sec as f64 * 1000.0
360            + milli as f64,
361    )
362}
363
364/// RFC-1123 / IMF-fixdate: `Wed, 21 Oct 2015 07:28:00 GMT`.
365fn parse_rfc1123(s: &str) -> Option<f64> {
366    // Drop an optional leading weekday token (`Wed,`).
367    let s = match s.split_once(", ") {
368        Some((_, rest)) => rest,
369        None => s,
370    };
371    let parts: Vec<&str> = s.split_whitespace().collect();
372    if parts.len() < 5 {
373        return None;
374    }
375    let d: i64 = parts[0].parse().ok()?;
376    let mo = MONTHS.iter().position(|m| *m == parts[1])? as i64;
377    let y: i64 = parts[2].parse().ok()?;
378    let tp: Vec<&str> = parts[3].split(':').collect();
379    if tp.len() < 2 {
380        return None;
381    }
382    let h: i64 = tp[0].parse().ok()?;
383    let mi: i64 = tp[1].parse().ok()?;
384    let sec: i64 = tp.get(2).map(|v| v.parse().ok()).unwrap_or(Some(0))?;
385    let days = days_from_civil(y, mo, d);
386    Some(
387        days as f64 * MS_PER_DAY
388            + h as f64 * 3_600_000.0
389            + mi as f64 * 60_000.0
390            + sec as f64 * 1000.0,
391    )
392}