Skip to main content

chrono_node/core/
js_date.rs

1//! The JavaScript `Date` operations chrono-node uses, emulated exactly.
2//!
3//! chrono-node's results depend on JS `Date` arithmetic: overflow in `setDate`/`setMonth`, the
4//! 0–99 year mapping of `new Date(y, m, d)`, and so on. This type reproduces those rules on a
5//! millisecond time value.
6//!
7//! **The system time zone is always UTC.** The golden data is recorded with `TZ=UTC`, so JS "local"
8//! getters and setters behave exactly like the UTC ones. A reference's own time zone is handled
9//! separately by `ReferenceWithTimezone`, as upstream does.
10
11use std::time::{SystemTime, UNIX_EPOCH};
12
13const MS_PER_DAY: i128 = 86_400_000;
14const MAX_TIME: i128 = 8_640_000_000_000_000;
15
16/// A JS `Date`: a millisecond time value, or `None` for an invalid date (JS `NaN`).
17/// Deliberately not `Ord`: compare `get_time()` values, so `NaN` comparisons stay explicit.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
19pub struct JsDate {
20    t: Option<i64>,
21}
22
23fn floor_div(a: i128, b: i128) -> i128 {
24    let q = a / b;
25    if (a % b != 0) && ((a < 0) != (b < 0)) {
26        q - 1
27    } else {
28        q
29    }
30}
31
32fn floor_mod(a: i128, b: i128) -> i128 {
33    a - floor_div(a, b) * b
34}
35
36/// Days since 1970-01-01 for a proleptic Gregorian date (month 1–12).
37fn days_from_civil(y: i128, m: i128, d: i128) -> i128 {
38    let y = if m <= 2 { y - 1 } else { y };
39    let era = floor_div(y, 400);
40    let yoe = y - era * 400;
41    let mp = (m + 9) % 12;
42    let doy = (153 * mp + 2) / 5 + d - 1;
43    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
44    era * 146_097 + doe - 719_468
45}
46
47/// (year, month 1–12, day 1–31) for days since 1970-01-01.
48fn civil_from_days(z: i128) -> (i128, i128, i128) {
49    let z = z + 719_468;
50    let era = floor_div(z, 146_097);
51    let doe = z - era * 146_097;
52    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
53    let y = yoe + era * 400;
54    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
55    let mp = (5 * doy + 2) / 153;
56    let d = doy - (153 * mp + 2) / 5 + 1;
57    let m = if mp < 10 { mp + 3 } else { mp - 9 };
58    (if m <= 2 { y + 1 } else { y }, m, d)
59}
60
61/// ECMAScript MakeDay: month is 0-based and may overflow in either direction.
62fn make_day(year: i64, month: i64, date: i64) -> Option<i128> {
63    let (year, month, date) = (year as i128, month as i128, date as i128);
64    let ym = year + floor_div(month, 12);
65    if ym.abs() > 400_000 {
66        return None;
67    }
68    let mn = floor_mod(month, 12);
69    Some(days_from_civil(ym, mn + 1, 1) + date - 1)
70}
71
72fn make_time(h: i64, m: i64, s: i64, ms: i64) -> i128 {
73    h as i128 * 3_600_000 + m as i128 * 60_000 + s as i128 * 1000 + ms as i128
74}
75
76fn time_clip(t: i128) -> Option<i64> {
77    (t.abs() <= MAX_TIME).then_some(t as i64)
78}
79
80fn make_date(day: Option<i128>, time: i128) -> Option<i64> {
81    day.and_then(|day| time_clip(day * MS_PER_DAY + time))
82}
83
84impl JsDate {
85    /// `new Date(NaN)`.
86    pub const INVALID: JsDate = JsDate { t: None };
87
88    /// `new Date(ms)`.
89    pub fn from_millis(ms: i64) -> JsDate {
90        JsDate {
91            t: time_clip(ms as i128),
92        }
93    }
94
95    /// `new Date()`.
96    pub fn now() -> JsDate {
97        let ms = SystemTime::now()
98            .duration_since(UNIX_EPOCH)
99            .map(|d| d.as_millis() as i64)
100            .unwrap_or(0);
101        JsDate::from_millis(ms)
102    }
103
104    /// `new Date(y, m, d, h, mi, s, ms)` (local time = UTC here). Like JS, years 0–99 mean 1900–1999.
105    pub fn local(year: i64, month: i64, day: i64, hour: i64, minute: i64, second: i64, ms: i64) -> JsDate {
106        let year = if (0..=99).contains(&year) { 1900 + year } else { year };
107        JsDate {
108            t: make_date(make_day(year, month, day), make_time(hour, minute, second, ms)),
109        }
110    }
111
112    /// `Date.UTC(...)`. Same as [`JsDate::local`] because the system time zone is UTC.
113    pub fn utc(year: i64, month: i64, day: i64, hour: i64, minute: i64, second: i64, ms: i64) -> JsDate {
114        JsDate::local(year, month, day, hour, minute, second, ms)
115    }
116
117    pub fn is_valid(&self) -> bool {
118        self.t.is_some()
119    }
120
121    /// `getTime()`; `None` is JS `NaN`.
122    pub fn get_time(&self) -> Option<i64> {
123        self.t
124    }
125
126    fn parts(&self) -> Option<(i128, i128, i128, i128, i128)> {
127        let t = self.t? as i128;
128        let day = floor_div(t, MS_PER_DAY);
129        let (y, m, d) = civil_from_days(day);
130        Some((y, m, d, day, t - day * MS_PER_DAY))
131    }
132
133    // Getters. On an invalid date JS returns NaN; we return 0. chrono-node never reads an
134    // invalid date's fields on the code paths the golden tests cover (see PORTING.md).
135
136    pub fn get_full_year(&self) -> i64 {
137        self.parts().map_or(0, |p| p.0 as i64)
138    }
139    /// 0-based, like JS.
140    pub fn get_month(&self) -> i64 {
141        self.parts().map_or(0, |p| p.1 as i64 - 1)
142    }
143    pub fn get_date(&self) -> i64 {
144        self.parts().map_or(0, |p| p.2 as i64)
145    }
146    /// 0 = Sunday.
147    pub fn get_day(&self) -> i64 {
148        self.parts().map_or(0, |p| floor_mod(p.3 + 4, 7) as i64)
149    }
150    pub fn get_hours(&self) -> i64 {
151        self.parts().map_or(0, |p| (p.4 / 3_600_000) as i64)
152    }
153    pub fn get_minutes(&self) -> i64 {
154        self.parts().map_or(0, |p| (p.4 / 60_000 % 60) as i64)
155    }
156    pub fn get_seconds(&self) -> i64 {
157        self.parts().map_or(0, |p| (p.4 / 1000 % 60) as i64)
158    }
159    pub fn get_milliseconds(&self) -> i64 {
160        self.parts().map_or(0, |p| (p.4 % 1000) as i64)
161    }
162    /// Always 0: the system time zone is UTC.
163    pub fn get_timezone_offset(&self) -> i64 {
164        0
165    }
166
167    fn time_within_day(&self) -> i128 {
168        self.parts().map_or(0, |p| p.4)
169    }
170
171    fn rebuild(&mut self, year: i64, month: i64, day: i64, time: i128) {
172        self.t = make_date(make_day(year, month, day), time);
173    }
174
175    /// `setFullYear(y)`. Like JS, an invalid date is treated as time 0 first.
176    pub fn set_full_year(&mut self, year: i64) {
177        if self.t.is_none() {
178            self.t = Some(0);
179        }
180        let (m, d, time) = (self.get_month(), self.get_date(), self.time_within_day());
181        self.rebuild(year, m, d, time);
182    }
183
184    /// `setMonth(m)` (0-based). Keeps the day of month, so Jan 31 + 1 month overflows into March.
185    pub fn set_month(&mut self, month: i64) {
186        if self.t.is_some() {
187            let (y, d, time) = (self.get_full_year(), self.get_date(), self.time_within_day());
188            self.rebuild(y, month, d, time);
189        }
190    }
191
192    /// `setDate(d)`.
193    pub fn set_date(&mut self, day: i64) {
194        if self.t.is_some() {
195            let (y, m, time) = (self.get_full_year(), self.get_month(), self.time_within_day());
196            self.rebuild(y, m, day, time);
197        }
198    }
199
200    fn set_time_fields(&mut self, h: i64, mi: i64, s: i64, ms: i64) {
201        if let Some((y, m, d, _, _)) = self.parts() {
202            self.t = make_date(make_day(y as i64, m as i64 - 1, d as i64), make_time(h, mi, s, ms));
203        }
204    }
205
206    pub fn set_hours(&mut self, hour: i64) {
207        let (mi, s, ms) = (self.get_minutes(), self.get_seconds(), self.get_milliseconds());
208        self.set_time_fields(hour, mi, s, ms);
209    }
210    pub fn set_minutes(&mut self, minute: i64) {
211        let (h, s, ms) = (self.get_hours(), self.get_seconds(), self.get_milliseconds());
212        self.set_time_fields(h, minute, s, ms);
213    }
214    pub fn set_seconds(&mut self, second: i64) {
215        let (h, mi, ms) = (self.get_hours(), self.get_minutes(), self.get_milliseconds());
216        self.set_time_fields(h, mi, second, ms);
217    }
218    pub fn set_milliseconds(&mut self, ms: i64) {
219        let (h, mi, s) = (self.get_hours(), self.get_minutes(), self.get_seconds());
220        self.set_time_fields(h, mi, s, ms);
221    }
222
223    /// `toISOString()`. `None` where JS would throw (invalid date).
224    pub fn to_iso_string(&self) -> Option<String> {
225        let (y, m, d, _, tod) = self.parts()?;
226        let (h, mi, s, ms) = (tod / 3_600_000, tod / 60_000 % 60, tod / 1000 % 60, tod % 1000);
227        let year = if (0..=9999).contains(&y) {
228            format!("{y:04}")
229        } else if y < 0 {
230            format!("-{:06}", -y)
231        } else {
232            format!("+{y:06}")
233        };
234        Some(format!("{year}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{ms:03}Z"))
235    }
236
237    /// Parses the `toISOString()` format (`YYYY-MM-DDTHH:mm:ss.sssZ`, or `±YYYYYY-...`).
238    pub fn parse_iso(s: &str) -> Option<JsDate> {
239        let (sign, rest) = match s.as_bytes().first()? {
240            b'+' => (1, &s[1..]),
241            b'-' => (-1, &s[1..]),
242            _ => (1, s),
243        };
244        let (date, time) = rest.strip_suffix('Z')?.split_once('T')?;
245        let mut dp = date.split('-');
246        let year: i64 = dp.next()?.parse().ok()?;
247        let month: i64 = dp.next()?.parse().ok()?;
248        let day: i64 = dp.next()?.parse().ok()?;
249        let (hms, ms) = time.split_once('.').unwrap_or((time, "0"));
250        let mut tp = hms.split(':');
251        let h: i64 = tp.next()?.parse().ok()?;
252        let mi: i64 = tp.next()?.parse().ok()?;
253        let sec: i64 = tp.next()?.parse().ok()?;
254        let ms: i64 = ms.parse().ok()?;
255        let t = make_date(make_day(sign * year, month - 1, day), make_time(h, mi, sec, ms))?;
256        Some(JsDate::from_millis(t))
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn epoch_and_iso() {
266        let d = JsDate::from_millis(0);
267        assert_eq!(d.to_iso_string().unwrap(), "1970-01-01T00:00:00.000Z");
268        assert_eq!(d.get_day(), 4); // Thursday
269        let d = JsDate::parse_iso("2012-08-09T00:00:00.000Z").unwrap();
270        assert_eq!(
271            (d.get_full_year(), d.get_month(), d.get_date(), d.get_day()),
272            (2012, 7, 9, 4)
273        );
274        assert_eq!(
275            JsDate::parse_iso("-000001-01-01T00:00:00.000Z")
276                .unwrap()
277                .to_iso_string()
278                .unwrap(),
279            "-000001-01-01T00:00:00.000Z"
280        );
281        assert_eq!(
282            JsDate::parse_iso("+012345-06-07T08:09:10.011Z")
283                .unwrap()
284                .to_iso_string()
285                .unwrap(),
286            "+012345-06-07T08:09:10.011Z"
287        );
288    }
289
290    #[test]
291    fn js_overflow_rules() {
292        // new Date(2012, 0, 31).setMonth(1) -> 2012-03-02 (leap year)
293        let mut d = JsDate::local(2012, 0, 31, 0, 0, 0, 0);
294        d.set_month(1);
295        assert_eq!(d.to_iso_string().unwrap(), "2012-03-02T00:00:00.000Z");
296        // setDate(0) -> last day of previous month
297        let mut d = JsDate::local(2013, 2, 15, 12, 0, 0, 0);
298        d.set_date(0);
299        assert_eq!(d.to_iso_string().unwrap(), "2013-02-28T12:00:00.000Z");
300        // negative month
301        let d = JsDate::local(2013, -1, 1, 0, 0, 0, 0);
302        assert_eq!(d.to_iso_string().unwrap(), "2012-12-01T00:00:00.000Z");
303        // hours overflow
304        let mut d = JsDate::local(2013, 11, 31, 23, 0, 0, 0);
305        d.set_hours(25);
306        assert_eq!(d.to_iso_string().unwrap(), "2014-01-01T01:00:00.000Z");
307        let mut d = JsDate::local(2013, 0, 1, 0, 0, 0, 0);
308        d.set_minutes(-1);
309        assert_eq!(d.to_iso_string().unwrap(), "2012-12-31T23:59:00.000Z");
310    }
311
312    #[test]
313    fn two_digit_years_map_to_1900s_like_js() {
314        assert_eq!(JsDate::local(99, 0, 1, 0, 0, 0, 0).get_full_year(), 1999);
315        let mut d = JsDate::local(0, 1, 29, 0, 0, 0, 0); // 1900 is not leap -> Mar 1
316        d.set_full_year(0);
317        assert_eq!(d.to_iso_string().unwrap(), "0000-03-01T00:00:00.000Z");
318    }
319
320    #[test]
321    fn time_clip_makes_invalid() {
322        assert!(!JsDate::local(300_000, 0, 1, 0, 0, 0, 0).is_valid());
323        assert!(JsDate::from_millis(8_640_000_000_000_000).is_valid());
324        assert!(!JsDate::from_millis(8_640_000_000_000_001).is_valid());
325        let mut d = JsDate::INVALID;
326        d.set_full_year(2000);
327        assert_eq!(d.to_iso_string().unwrap(), "2000-01-01T00:00:00.000Z");
328    }
329}