Skip to main content

alibi_core/utils/datetime/
parse.rs

1//! JavaScript Date.parse results, including ISO day overflow and
2//! fractional-second truncation. Invalid dates deliberately impose no bound.
3
4use crate::utils::javascript::trim as js_trim;
5use chrono::{DateTime, Datelike, Local, NaiveDate, NaiveDateTime, Offset, TimeZone};
6
7#[must_use]
8pub fn parse_date_millis(value: &str) -> Option<i64> {
9    let value = js_trim(value);
10    parse_iso(value).or_else(|| parse_legacy(value))
11}
12
13fn digits(input: &mut &str, width: usize) -> Option<i64> {
14    let prefix = input.get(..width)?;
15    if !prefix.bytes().all(|byte| byte.is_ascii_digit()) {
16        return None;
17    }
18    let value = prefix.parse().ok()?;
19    *input = input.get(width..)?;
20    Some(value)
21}
22
23fn separator(input: &mut &str, expected: char) -> Option<()> {
24    *input = input.strip_prefix(expected)?;
25    Some(())
26}
27
28fn parse_iso(mut input: &str) -> Option<i64> {
29    let year = match input.as_bytes().first() {
30        Some(b'+' | b'-') => {
31            let negative = input.starts_with('-');
32            input = input.get(1..)?;
33            let year = digits(&mut input, 6)?;
34            if negative && year == 0 {
35                return None;
36            }
37            if negative { -year } else { year }
38        }
39        _ => digits(&mut input, 4)?,
40    };
41    let mut month = 1;
42    let mut day = 1;
43    if input.starts_with('-') {
44        separator(&mut input, '-')?;
45        month = digits(&mut input, 2)?;
46        if input.starts_with('-') {
47            separator(&mut input, '-')?;
48            day = digits(&mut input, 2)?;
49        }
50    }
51    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
52        return None;
53    }
54    let days = civil_days(year, month, day);
55    if input.is_empty() {
56        return time_clip(days.checked_mul(86_400_000)?);
57    }
58    if !input.starts_with(['T', 't', ' ']) {
59        return None;
60    }
61    input = input.get(1..)?;
62    let hour = digits(&mut input, 2)?;
63    separator(&mut input, ':')?;
64    let minute = digits(&mut input, 2)?;
65    let mut second = 0;
66    let mut milliseconds = 0;
67    let mut fractional_nonzero = false;
68    if input.starts_with(':') {
69        separator(&mut input, ':')?;
70        second = digits(&mut input, 2)?;
71        if input.starts_with('.') {
72            separator(&mut input, '.')?;
73            let length = input.bytes().take_while(u8::is_ascii_digit).count();
74            if length == 0 {
75                return None;
76            }
77            fractional_nonzero = input.get(..length)?.bytes().any(|digit| digit != b'0');
78            let first = input.get(..length.min(3))?;
79            milliseconds =
80                first.parse::<i64>().ok()? * 10i64.pow(u32::try_from(3 - first.len()).ok()?);
81            input = input.get(length..)?;
82        }
83    }
84    if hour > 24
85        || minute > 59
86        || second > 59
87        || (hour == 24 && (minute != 0 || second != 0 || fractional_nonzero))
88    {
89        return None;
90    }
91    let nominal = days.checked_mul(86_400_000)?
92        + hour * 3_600_000
93        + minute * 60_000
94        + second * 1_000
95        + milliseconds;
96    if input.is_empty() {
97        return time_clip(local_millis(nominal)?);
98    }
99    if input == "Z" || input == "z" {
100        return time_clip(nominal);
101    }
102    let negative = input.starts_with('-');
103    if !negative && !input.starts_with('+') {
104        return None;
105    }
106    input = input.get(1..)?;
107    let offset_hour = digits(&mut input, 2)?;
108    if input.starts_with(':') {
109        separator(&mut input, ':')?;
110    }
111    let offset_minute = digits(&mut input, 2)?;
112    if !input.is_empty() || offset_hour > 23 || offset_minute > 59 {
113        return None;
114    }
115    let offset = (offset_hour * 60 + offset_minute) * 60_000;
116    time_clip(nominal + if negative { offset } else { -offset })
117}
118
119fn time_clip(milliseconds: i64) -> Option<i64> {
120    (milliseconds.abs() <= 8_640_000_000_000_000).then_some(milliseconds)
121}
122
123// Gregorian arithmetic lets expanded ISO years reach JavaScript's TimeClip
124// boundary without inheriting Chrono's smaller calendar-year range.
125fn civil_days(year: i64, month: i64, day: i64) -> i64 {
126    let adjusted_year = year - i64::from(month <= 2);
127    let era = adjusted_year.div_euclid(400);
128    let year_in_era = adjusted_year - era * 400;
129    let march_month = month + if month > 2 { -3 } else { 9 };
130    let day_in_year = (153 * march_month + 2) / 5 + day - 1;
131    let day_in_era = year_in_era * 365 + year_in_era / 4 - year_in_era / 100 + day_in_year;
132    era * 146_097 + day_in_era - 719_468
133}
134
135fn local_millis(nominal: i64) -> Option<i64> {
136    let naive = DateTime::from_timestamp_millis(nominal)?.naive_utc();
137    if let Some(date) = Local.from_local_datetime(&naive).earliest() {
138        return Some(date.timestamp_millis());
139    }
140    // A local clock skipped by a daylight-saving transition advances by the
141    // gap, using the offset before that transition (ECMAScript compatible).
142    let prior = Local
143        .timestamp_millis_opt(nominal.checked_sub(86_400_000)?)
144        .single()?;
145    let offset = i64::from(prior.offset().fix().local_minus_utc()) * 1_000;
146    nominal.checked_sub(offset)
147}
148
149fn parse_legacy(value: &str) -> Option<i64> {
150    let value = remove_comments(value)?;
151    let value = value.trim();
152    let value = strip_weekday(value).trim();
153    if let Ok(date) = DateTime::parse_from_rfc2822(value) {
154        return time_clip(date.timestamp_millis());
155    }
156    let value = value.replace(',', " ");
157    let value = normalize_legacy(&value);
158    let without_gmt = value.replace("GMT+", "+").replace("GMT-", "-");
159    // Chrono needs an explicit offset; literal GMT/UTC format tokens do not
160    // supply one. JavaScript accepts these names as zero-offset bounds.
161    let without_gmt = without_gmt
162        .strip_suffix(" GMT")
163        .or_else(|| without_gmt.strip_suffix(" UTC"))
164        .map_or_else(|| without_gmt.clone(), |prefix| format!("{prefix} +0000"));
165    for format in [
166        "%b %d %Y %H:%M:%S%.f %z",
167        "%d %b %Y %H:%M:%S%.f %z",
168        "%b %d %Y %H:%M:%S%.f GMT",
169        "%d %b %Y %H:%M:%S%.f GMT",
170        "%b %d %Y %H:%M:%S%.f UTC",
171        "%d %b %Y %H:%M:%S%.f UTC",
172    ] {
173        if let Ok(date) = DateTime::parse_from_str(&without_gmt, format) {
174            return time_clip(date.timestamp_millis());
175        }
176    }
177    for format in [
178        "%b %d %Y %H:%M:%S%.f",
179        "%d %b %Y %H:%M:%S%.f",
180        "%m/%d/%Y %H:%M:%S%.f",
181        "%Y/%m/%d %H:%M:%S%.f",
182        "%Y-%m-%d %H:%M:%S%.f",
183        "%b %d %Y %I:%M:%S %p",
184        "%d %b %Y %I:%M:%S %p",
185    ] {
186        if let Ok(date) = NaiveDateTime::parse_from_str(&value, format) {
187            return time_clip(local_millis(date.and_utc().timestamp_millis())?);
188        }
189    }
190    for format in [
191        "%b %d %Y", "%d %b %Y", "%m/%d/%Y", "%Y/%m/%d", "%m-%d-%Y", "%Y-%m-%d",
192    ] {
193        if let Ok(mut date) = NaiveDate::parse_from_str(&value, format) {
194            if (0..=99).contains(&date.year()) {
195                date = date.with_year(if date.year() < 50 {
196                    2000 + date.year()
197                } else {
198                    1900 + date.year()
199                })?;
200            }
201            return time_clip(local_millis(
202                date.and_hms_opt(0, 0, 0)?.and_utc().timestamp_millis(),
203            )?);
204        }
205    }
206    if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) {
207        let number: i64 = value.parse().ok()?;
208        if number > 1_000_000 {
209            return None;
210        }
211        let (year, month) = match number {
212            0 => (2000, 1),
213            1..=12 => (2001, number),
214            13..=31 => return None,
215            32..=49 => (2000 + number, 1),
216            50..=99 => (1900 + number, 1),
217            _ => (number, 1),
218        };
219        return time_clip(civil_days(year, month, 1).checked_mul(86_400_000)?);
220    }
221    None
222}
223
224fn strip_weekday(value: &str) -> &str {
225    let Some(prefix) = value.get(..3) else {
226        return value;
227    };
228    if ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
229        .iter()
230        .any(|weekday| prefix.eq_ignore_ascii_case(weekday))
231    {
232        return value
233            .get(3..)
234            .unwrap_or(value)
235            .trim_start_matches([',', ' ']);
236    }
237    value
238}
239
240fn normalize_legacy(value: &str) -> String {
241    let months = [
242        ("January", "Jan"),
243        ("February", "Feb"),
244        ("March", "Mar"),
245        ("April", "Apr"),
246        ("May", "May"),
247        ("June", "Jun"),
248        ("July", "Jul"),
249        ("August", "Aug"),
250        ("September", "Sep"),
251        ("October", "Oct"),
252        ("November", "Nov"),
253        ("December", "Dec"),
254    ];
255    let mut parts = value
256        .split_whitespace()
257        .map(|part| {
258            months
259                .iter()
260                .find(|(name, _)| part.eq_ignore_ascii_case(name))
261                .map_or(part, |(_, abbreviation)| *abbreviation)
262        })
263        .collect::<Vec<_>>();
264    // These named North American offsets are accepted by Date.parse. Match a
265    // whole final token so an arbitrary suffix cannot become a valid timezone.
266    let zones = [
267        ("UT", "+0000"),
268        ("UTC", "+0000"),
269        ("GMT", "+0000"),
270        ("EST", "-0500"),
271        ("EDT", "-0400"),
272        ("CST", "-0600"),
273        ("CDT", "-0500"),
274        ("MST", "-0700"),
275        ("MDT", "-0600"),
276        ("PST", "-0800"),
277        ("PDT", "-0700"),
278    ];
279    if let Some(last) = parts.last_mut()
280        && let Some((_, offset)) = zones
281            .iter()
282            .find(|(name, _)| last.eq_ignore_ascii_case(name))
283    {
284        *last = offset;
285    }
286    parts.join(" ")
287}
288
289fn remove_comments(value: &str) -> Option<String> {
290    let mut result = String::new();
291    let mut depth = 0usize;
292    for character in value.chars() {
293        match character {
294            '(' => depth = depth.checked_add(1)?,
295            ')' if depth > 0 => depth -= 1,
296            ')' => return None,
297            _ if depth == 0 => result.push(character),
298            _ => {}
299        }
300    }
301    (depth == 0).then_some(result)
302}