1
0
Fork 0
mirror of https://github.com/LadybirdBrowser/ladybird.git synced 2025-06-08 05:27:14 +09:00
ladybird/Libraries/LibJS/Runtime/DateParser.h

937 lines
32 KiB
C++

/*
* Copyright (c) 2025, Manuel Zahariev <manuel@duck.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Error.h>
#include <AK/GenericLexer.h>
#include <AK/Optional.h>
#include <AK/StringView.h>
#include <AK/Time.h>
#include <AK/Types.h>
#include <AK/Vector.h>
#include <LibJS/Runtime/Date.h>
#include <cctype> // Not included by default on macOS.
// Parse simplified ISO8601 and non-standard date formats to milliseconds
// from epoch (double). Synopsis:
// (1) Try to parse the string as simplified ISO8601 (case sensitive).
// - if that worked, assemble result (4 below)
// - hard fail (return NAN) if the input string "looks like" ISO8601,
// but deviates.
// (2) If parsing ISO8601 "soft" fails (unlike "hard" above), continue
// shallow parsing (case insensitive) date string components: time,
// timezone, keywords, numbers...
// (3) Guess ambiguous date parts, like "1/2/3" --> Jan 2, 2003
// (4) Assemble result from parts (year, month,...) and convert to milliseconds
// from epoch.
//
// Overall objectives:
// - compliance with ECMA date time string format, incl. ISO8601 extensions
// for signed 6-digit year and extended time offset format (:SS.nanosecs)
// https://tc39.es/ecma262/#sec-date-time-string-format
// - Support for Date.toString and Date.toUTCString formats.
// - Compatible with Mozilla Firefox 134.0.1 and Chromium 131.0.6778.264. Within
// reason. Differences indicated in comments.
// - Where Firefox and Chrome agree on a parse, support it.
// - Where they disagree, support the one that seems more sane.
// - In very limited cases, pick our own way. Example: "<number> Month":
// - Firefox fails on all "<number> Month" date strings.
// - In most cases, Chrome interprets "<number> Month" as "Month 01, Year".
// - Chrome parses "7 Feb" as "Feb 7, 2001".
// - We always parse as "Month 01, Year".
// - Support Firefox less permissive punctuation but more permissive punctuation
// syntax.
class DateParser : public GenericLexer {
private:
Vector<u64> m_numbers;
Optional<i8> m_sign;
Optional<i64> m_year;
Optional<u8> m_month;
Optional<u8> m_day;
Optional<u8> m_hours;
Optional<u8> m_minutes;
Optional<u8> m_seconds;
Optional<u16> m_milliseconds;
// True if GMT/UTC/Z specified and there is no timezone offset; false if timezone offset.
// No value if there is no timezone information: we have to guess whether the date was given in GMT or local time.
Optional<bool> m_timezone_utc;
Optional<i8> m_timezone_sign; // +1 or -1 if there is a timezone offset.
Optional<u8> m_timezone_hours;
Optional<u8> m_timezone_minutes;
Optional<u8> m_timezone_seconds;
Optional<u64> m_timezone_nanoseconds;
constexpr static u64 pow10(size_t pow)
{
switch (pow) {
case 0:
return 1;
case 1:
return 10;
case 2:
return 100;
case 3:
return 1'000;
case 4:
return 10'000;
case 5:
return 100'000;
case 6:
return 1'000'000;
case 7:
return 10'000'000;
case 8:
return 100'000'000;
case 9:
return 1'000'000'000;
}
VERIFY_NOT_REACHED();
}
// Reads a contiguous sequence of digits. Ignores digits read past MaxLength.
// Returns the numeric value of the sequence and the number of digits not ignored.
template<typename T, size_t MaxLength>
ALWAYS_INLINE std::pair<T, size_t> read_number()
{
static_assert(std::is_unsigned<T>(), "Type must be unsigned.");
static_assert(MaxLength < 11, "Number too long.");
static_assert((sizeof(T) >= 1) || (MaxLength <= 2), "Number too long.");
static_assert((sizeof(T) >= 2) || (MaxLength <= 4), "Number too long.");
static_assert((sizeof(T) >= 4) || (MaxLength <= 9), "Number too long.");
static_assert((sizeof(T) >= 8) || (MaxLength <= 19), "Number too long.");
auto result = std::make_pair(T(0), size_t(0));
for (result.second = 0; result.second < MaxLength; ++result.second) {
if (is_eof() || !next_is(isdigit))
return result;
result.first *= 10;
result.first += consume() - '0';
}
ignore_while(isdigit);
return result;
}
ALWAYS_INLINE bool guess_date_from_numbers() // Guess an ambiguous date, like 1/1/1.
{
switch (m_numbers.size()) {
case 0:
return true; // The year and possibly month may have already been calculated. Verify later
case 1:
return guess_date_from_1_number();
case 2:
return guess_date_from_2_numbers();
case 3:
return guess_date_from_3_numbers();
}
return false; // Too many numbers
}
ALWAYS_INLINE bool guess_date_from_3_numbers() // Only "month-day-year" (default) and "year-month-day" are supported (same as firefox and chrome).
{
VERIFY(m_numbers.size() == 3);
u64 const number0 = m_numbers.at(0);
u64 const number1 = m_numbers.at(1);
u64 const number2 = m_numbers.at(2);
if (m_year.has_value() || m_month.has_value())
return false; // Too many numbers
if ((number0 > 31) || number0 == 0) { // YMD
if (number1 > 12)
return false;
if (number2 > 31)
return false;
if ((number1 == 0) || (number2 == 0)) // 0 for day or month
return false;
m_month = number1;
m_day = number2;
m_year = guess_year(number0);
return true;
}
// MDY
if (number0 > 12)
return false; // Both Firefox and Chrome fail for the first number >12 and <=31. Weird. We do the same
if (number1 > 31)
return false;
if ((number0 == 0) || (number1 == 0)) // 0 for day or month
return false;
m_month = number0;
m_day = number1;
m_year = guess_year(number2);
return true;
}
ALWAYS_INLINE bool guess_date_from_2_numbers() // Guess default order "day-year" or adapt.
{
VERIFY(m_numbers.size() == 2);
u64 const number0 = m_numbers.at(0);
u64 const number1 = m_numbers.at(1);
if (m_year.has_value() && m_month.has_value())
return false; // Too many numbers
if (m_year.has_value()) {
if (number0 <= 12) { // ... is a month
if (number1 > 31)
return false;
if ((number0 == 0) || (number1 == 0)) // 0 for day or month
return false;
m_month = number0;
m_day = number1;
return true;
}
if (number0 <= 31) { // ... is a day
if (number1 > 12)
return false;
if ((number0 == 0) || (number1 == 0)) // 0 for day or month
return false;
m_month = number1;
m_day = number0;
return true;
}
}
// At this point, one of the numbers is the year
if (!m_month.has_value())
return false; // Firefox fails on guessing 2 numbers. We do the same
// At this point, the month has been read from a month name
if ((number0 > 31) && (number1 > 31))
return false; // Neither of the numbers can be a day
if ((number0 > 31) || (number0 == 0)) { // ... is a year
if (number1 == 0) // 0 for day
return false;
m_day = number1;
m_year = guess_year(number0);
return true;
}
// Default order is day -> year
if (number0 == 0) // 0 for day
return false;
m_day = number0;
m_year = guess_year(number1);
return true;
}
ALWAYS_INLINE bool guess_date_from_1_number() // Guess in this order: year, month, day.
{
VERIFY(m_numbers.size() == 1);
u64 const number0 = m_numbers.at(0);
if (!m_year.has_value() && !m_month.has_value())
return one_number(number0);
if (!m_year.has_value()) { // the number must be a year
m_year = guess_year(number0);
return true;
}
if (!m_month.has_value()) // Firefox fails on two numbers. So do we. Chrome is weird.
return false;
// At this point, the year and month must have been specified some other way (e.g. "Feb +002002").
if ((number0 > 31) || (number0 == 0)) // Invalid day number.
return false;
m_day = number0;
return true;
}
ALWAYS_INLINE static u64 guess_year(u64 const number) // Guess one- or two-digit year.
{
switch (number) {
case 0 ... 49:
return 2000 + number;
case 50 ... 99:
return 1900 + number;
}
return number;
}
ALWAYS_INLINE bool one_number(u64 const number) // The whole input string is just one stand-alone number.}
{
switch (number) {
case 0:
m_year = 2000;
return true;
case 1 ... 12:
m_year = 2001;
m_month = number; // Firefox and Chrome interpret standalone numbers below 12 as months in 2001. Weird! We do the same.
return true;
case 13 ... 31:
return false; // Firefox and Chrome fail on standalone numbers between 12 and 31. Weird! We do the same.
case 32 ... 49:
m_year = 2000 + number;
return true;
case 50 ... 99:
m_year = 1900 + number;
return true;
}
m_year = number;
return true;
}
// Permissive, greedy shallow date parser for date components.
// Returns:
// - false: Hard fail. Some invalid input condition has been found. Caller should return NAN.
// - true: Parsing can continue.
ALWAYS_INLINE bool loop()
{
switch (peek()) {
case '0' ... '9':
return maybe_number(); // Also captures time.
case '+':
case '-':
return maybe_sign(); // Also captures signed 6-digit year and timezone offset.
case 'A' ... 'Z':
return maybe_word(); // Captures all date string "keywords". Accepts any kind of "junk" before date and time..
case ' ':
case '.':
case ',':
case '/':
// Firefox seems to accept (ignore) this punctuation. So do we.
// Firefox also accepts a bare '+' sometimes. We do not.
// Chrome is a lot more permissive.
ignore(); // Ignore punctuation.
return true;
case '(':
ignore_until(')'); // Consume time zone name (Anything in brackets).
ignore();
ignore_while(isspace);
return true;
}
return false;
}
ALWAYS_INLINE bool maybe_ampm() // Side effect: consumes space at the end of a time component, even if there is no AM/PM.
{
VERIFY(m_hours.has_value());
consume_while(isspace);
if (consume_specific("AM"sv)) {
if (!separator())
return false; // 12:34 AMsomething
if (m_hours.value() > 12)
return false; // 14:45AM
if (m_hours.value() == 12)
m_hours = 0; // 12:05AM -> 00:05
return true;
}
if (consume_specific("PM"sv)) {
if (!separator())
return false; // 12:34 PMsomething
if (m_hours.value() > 12)
return false; // 14:45PM
if (m_hours.value() < 12)
m_hours = m_hours.value() + 12;
return true;
}
return true;
}
ALWAYS_INLINE bool maybe_time(std::pair<u32, size_t> const& hours) // H[H]:MM[:SS[.mss[...]]][ ][AM|PM] At this point, H[H]: has already been read.
{
if (m_hours.has_value())
return false; // Time has already been read.
if (hours.second > 2)
return false; // 123:
if (hours.first > 24)
return false;
m_hours = hours.first; // No precision loss during the conversion u32 -> u8 since the hours has at most 2 digits.
auto const minutes = read_number<u32, 3>();
if (minutes.second != 2)
return false; // 12:345 or 12:3
if (minutes.first > 59)
return false;
m_minutes = minutes.first;
if (consume_specific('.'))
return false; // 12:34.
if (!consume_specific(':'))
return true;
auto const seconds = read_number<u32, 3>();
if (seconds.second != 2)
return false; // 12:34:567 or 12:34:5
if (seconds.first > 59)
return false;
m_seconds = seconds.first;
if (!consume_specific('.'))
return true;
auto const milliseconds = read_number<u32, 3>();
switch (milliseconds.second) {
case 0:
return false; // 12:34:56.
case 1:
m_milliseconds = milliseconds.first * 100;
break;
case 2:
m_milliseconds = milliseconds.first * 10;
break;
case 3:
m_milliseconds = milliseconds.first;
break;
default:
VERIFY_NOT_REACHED();
}
return true;
}
ALWAYS_INLINE bool maybe_number()
{
auto const number = read_number<u32, 7>();
VERIFY(number.second > 0);
if (number.second > 6)
return false; // 1234567
if (consume_specific(':')) {
if (!maybe_time(number))
return false;
if (!maybe_ampm())
return false;
return true;
}
m_numbers.append(number.first);
return separator(); // Must be followed by a separator.
}
ALWAYS_INLINE bool maybe_sign()
{
i8 const sign = (consume() == '-') ? -1 : +1;
auto const number = read_number<u32, 7>();
switch (number.second) {
case 0: // Not a sign after all; '+' is forbidden if it is not a sign. '-' is ignored as punctuation.
return (sign == -1);
case 1 ... 5: // Too small to be a signed year;
if (m_hours.has_value())
break; // Candidate for timezone offset.
m_numbers.append(number.first); // Ignore the sign and treat it as a number.
return true;
case 6:
if (m_hours.has_value())
break; // Candidate for timezone offset.
if (m_year.has_value())
return false; // To many digits to be anything else than a signed year.
m_year = sign * number.first; // Candidate for signed year
return true;
default:
return false;
}
m_timezone_sign = sign;
return tz_offset(number);
}
ALWAYS_INLINE bool tz_offset() // Read a full timezone offset, including the sign.
{
m_timezone_sign = (consume() == '-') ? -1 : +1;
auto const number = read_number<u32, 7>();
return tz_offset(number);
}
// Continue reading a timezone offset, after the sign and the first number have beeen read.
ALWAYS_INLINE bool tz_offset(std::pair<u32, size_t> const& number, bool iso_8601_format = false)
{
if (m_timezone_hours.has_value()) // Cannot have more than one timezone offset or a timezone name followed by a timezone offset.
return false;
m_timezone_utc = false;
switch (number.second) {
case 0:
return false;
case 1:
if (iso_8601_format)
return false; // +1
[[fallthrough]];
case 2:
break; // Candidate for timezone offset with colon.
case 3:
if (iso_8601_format)
return false; // +123
[[fallthrough]];
case 4: { // "Military" timezone offset
u16 const tz_hhmm = number.first;
m_timezone_hours = tz_hhmm / 100;
m_timezone_minutes = tz_hhmm % 100;
return true;
}
case 5:
if (iso_8601_format)
return false; // +12345
[[fallthrough]];
case 6: { // "Military" timezone offset
u32 const tz_hhmmss = number.first;
m_timezone_hours = tz_hhmmss / 10000;
m_timezone_minutes = tz_hhmmss % 10000 / 100;
m_timezone_seconds = tz_hhmmss % 100;
return true;
}
default:
return false;
}
m_timezone_hours = number.first; // Guaranteed to be a 1- or 2-digit number.
if (!consume_specific(':'))
return true; // +H[H] "military" time offset
// Timezone with colon
auto const minutes = read_number<u16, 3>();
if (minutes.second != 2)
return false;
m_timezone_minutes = minutes.first;
if (!consume_specific(':'))
return true;
auto const seconds = read_number<u16, 3>();
if (seconds.second != 2)
return false;
m_timezone_seconds = seconds.first;
if (!consume_specific('.'))
return true;
auto const nanoseconds = read_number<u64, 10>();
if (nanoseconds.second == 0)
return false;
if (nanoseconds.second > 9)
return false;
m_timezone_nanoseconds = nanoseconds.first * pow10(9 - nanoseconds.second);
return true;
}
ALWAYS_INLINE bool separator() // Ignore space and Firefox punctuation.
{
if (is_eof())
return true;
switch (peek()) {
case ' ':
case ',':
case '.':
case '/':
case '-':
ignore();
return true;
}
return false;
}
ALWAYS_INLINE bool gmt(StringView const& str) // Z or GMT or UTC can be used interchangeably.
{
if (!consume_specific(str))
return false;
m_timezone_utc = true;
bool space = consume_while(isspace).length() > 0;
switch (peek()) {
case '+':
case '-':
return tz_offset(); // GMT+1234
}
return space || separator();
}
// Same as Chrome and Firefox, we only support abbreviations for timezones covering the US mainland.
ALWAYS_INLINE bool us_timezone(StringView const& str, u8 hours, u8 minutes = 0, i8 sign = -1)
{
VERIFY(str.length() == 3); // only 3-letter timezone names
if (!consume_specific(str))
return false;
if (!m_hours.has_value() && m_numbers.is_empty() && !m_year.has_value()) // Ignore timezone before date or time.
return false;
m_timezone_sign = sign;
m_timezone_hours = hours;
m_timezone_minutes = minutes;
return separator(); // Must end with a separator.
}
ALWAYS_INLINE bool month_name(StringView const& str, u8 month)
{
VERIFY(str.length() == 3); // Only looking for 3-letter month prefixes.
for (size_t i = 0; i < 3; ++i)
if (str[i] != peek(i))
return false;
ignore_while(isalpha); // ... which can followed by anything. Just like Firefox and Chrome.
m_month = month;
return separator(); // Must end with a separator.
}
ALWAYS_INLINE bool word() // Alphanumeric strings that are not date "keywords".
{
std::ignore = consume_while(isalpha);
// Just like Firefox and Chrome:
// - Ignore junk (bare words) at the beginning (before time or a date fragment has been read).
// - Fail if a word is read later in the date string (exception: final time zone name, in brackets).
return (m_numbers.is_empty() && !m_hours.has_value() && !m_year.has_value());
}
ALWAYS_INLINE bool maybe_word() // The top of a trie catching date "keywords".
{
switch (peek()) {
case 'A': // APR AUG
return month_name("APR"sv, 4) || month_name("AUG"sv, 8) || word();
case 'C': // CST CDT
return us_timezone("CST"sv, 6) || us_timezone("CDT"sv, 5) || word();
case 'D': // DEC
return month_name("DEC"sv, 12) || word();
case 'E': // EST EDT
return us_timezone("EST"sv, 5) || us_timezone("EDT"sv, 4) || word();
case 'F': // FEB
return month_name("FEB"sv, 2) || word();
case 'G': // GMT
return gmt("GMT"sv) || word();
case 'J': // JAN JUN JUL
return month_name("JAN"sv, 1) || month_name("JUN"sv, 6) || month_name("JUL"sv, 7) || word();
case 'M': // MAR MAY MST MDT
return month_name("MAR"sv, 3) || month_name("MAY"sv, 5) || us_timezone("MST"sv, 7) || us_timezone("MDT"sv, 6) || word();
case 'N': // NOV
return month_name("NOV"sv, 11) || word();
case 'O': // OCT
return month_name("OCT"sv, 10) || word();
case 'P': // PST PDT
return us_timezone("PST"sv, 8) || us_timezone("PDT"sv, 7) || word();
case 'S': // SEP
return month_name("SEP"sv, 9) || word();
case 'U': // UTC
return gmt("UTC"sv) || word();
case 'Z': // Z
return gmt("Z"sv) || word();
}
return word();
}
// Capture simplified ISO8601 date format. https://tc39.es/ecma262/#sec-date-time-string-format
// All of the iso_... functions return:
// - true: if the input can be parsed as an ISO8601 date.
// - false: cannot be parsed as an ISO8601 date. Will be defered to a non-standard date string.
// - Error: hard fail; caller is supposed to return NAN.
ALWAYS_INLINE ErrorOr<bool> maybe_iso_8601()
{
for (size_t step = 0; step < 4; ++step) {
switch (step) {
case 0:
if (!TRY(maybe_iso_year()))
return false;
break;
case 1:
if (!TRY(maybe_iso_month_day()))
return false;
break;
case 2:
if (!TRY(maybe_iso_time()))
return false;
break;
case 3:
TRY(maybe_iso_tz());
break;
default:
VERIFY_NOT_REACHED();
}
if (is_eof())
return true;
}
return Error::from_string_literal("Read ISO8601 format, but have some input left over.");
}
ALWAYS_INLINE ErrorOr<bool> maybe_iso_year()
{
switch (peek()) {
case '0' ... '9':
if (TRY(maybe_iso_year4()))
return true;
break;
case '+':
case '-':
if (TRY(maybe_iso_signed_year6()))
return true;
break;
default:
return false;
}
return false;
}
ALWAYS_INLINE ErrorOr<bool> maybe_iso_year4() // Like "2025".
{
auto number = read_number<u32, 7>();
switch (number.second) {
case 0:
return false; // no digits
case 1:
case 2:
if (next_is(':')) { // This may not be a year after all but the start of a "time" component.
ignore();
if (!maybe_time(number))
return Error::from_string_literal("Cannot parse time.");
if (!maybe_ampm())
return Error::from_string_literal("Cannot parse am/pm.");
return false;
}
[[fallthrough]];
case 3:
case 5:
case 6: // Six-digit year number; no sign means not ISO8601 date.
// At this point, this is not an ISO8601 date.
m_numbers.append(number.first);
return false;
case 4: // Four-digit year number.
m_year = number.first;
return true; // This can be the start of an ISO8601 date.
}
return Error::from_string_literal("String too long to be a year.");
}
ALWAYS_INLINE ErrorOr<bool> maybe_iso_signed_year6() // Like "+002025".
{
i64 sign = +1;
if (consume() == '-')
sign = -1;
auto number = read_number<u32, 7>();
switch (number.second) {
case 0:
if (sign == +1) // Standalone '+' is invalid.
return Error::from_string_literal("Invalid character in date string ('+').");
return false;
case 1 ... 5:
m_numbers.append(number.first); // This is not an ISO8601 date.
return false;
case 6:
break;
default:
return Error::from_string_literal("String too long to be a 6-digit signed year.");
}
m_year = sign * number.first;
// "The representation of the year 0 as -000000 is invalid." https://tc39.es/ecma262/#sec-expanded-years
// Firefox interprets "-000000" as "Jan 1, 2000".
if ((sign == -1) && (m_year == 0))
return Error::from_string_literal("The representation of the year 0 as '-000000' is invalid.");
return true;
}
ALWAYS_INLINE ErrorOr<bool> maybe_iso_month_day() // [-MM[-DD]]
{
if (!consume_specific('-'))
return true;
auto month = read_number<u16, 3>();
if (consume_specific(':')) { // Like "2000-12:34". Firefox and Chrome parse it correctly. We do the same.
if (maybe_time(month))
return false;
return Error::from_string_literal("Found something that looks like time, but is not.");
}
switch (month.second) {
case 0:
return false; // Not ISO8601 date format. Continue reading.
case 1:
m_month = month.first;
if (m_month == 0)
return Error::from_string_literal("Month number cannot be zero.");
return false;
case 2:
break;
default: // month number too long
return Error::from_string_literal("Month number too long.");
}
m_month = month.first;
if ((m_month == 0) || (m_month.value() > 12))
return Error::from_string_literal("Invalid month number.");
if (!consume_specific('-'))
return true;
if (is_eof())
return Error::from_string_literal("Expecting month number. Got eof.");
auto day = read_number<u16, 3>();
switch (day.second) {
case 0:
return false; // Not ISO8601 date format. Continue reading.
case 1:
m_day = day.first;
if (m_day == 0)
return Error::from_string_literal("Day number cannot be zero.");
return false;
case 2:
break;
default: // month number too long
return Error::from_string_literal("Day number too long.");
}
m_day = day.first;
if ((m_day == 0) || (m_day.value() > 31))
return Error::from_string_literal("Invalid day number.");
return true;
}
ALWAYS_INLINE ErrorOr<bool> maybe_iso_time() // THH:MM[:SS[.M[SS...]]]
{
// The ECMA date string format requires uppercase 'T' and 'Z' https://tc39.es/ecma262/#sec-date-time-string-format
// - Chrome supports lower case occurrences.
// - Firefox and us do not.
if (!consume_specific('T'))
return false;
// After reading the 'T', any failures return NAN (failed parse)
auto hours = read_number<u16, 3>();
if (!consume_specific(':')) // 12
return Error::from_string_literal("Well specified time needs minutes.");
if (hours.second != 2)
return Error::from_string_literal("Hours: invalid length.");
if (!maybe_time(hours)) // The only difference is that ISO8601 requires 2-digit hours.
return Error::from_string_literal("Cannot parse time.");
return true;
}
ALWAYS_INLINE ErrorOr<void> maybe_iso_tz() // After the 'T' for iso_time has been read, reading an ISO8601 timezone either succeeds or the whole parse fails.
{
switch (consume()) {
case 'Z':
m_timezone_utc = true;
return {};
case '-':
m_timezone_sign = -1;
break;
case '+':
m_timezone_sign = +1;
break;
default:
return Error::from_string_literal("Invalid timezone offset format.");
}
if (!tz_offset(read_number<u32, 7>(), true)) // A sign and a number have been read. Continue reading a timezone offset..
return Error::from_string_literal("Invalid timezone offset format.");
return {};
}
ALWAYS_INLINE double build_date(bool is_iso8601_date = false) // Build a date (milliseconds since epoch) from parts collected.
{
VERIFY(is_eof());
if (!m_year.has_value())
return NAN; // Needs at least one year.
if ((m_hours == 24) && ((m_minutes.value_or(0) > 0) || (m_seconds.value_or(0) > 0)))
return NAN; // 24:01:02
AK::UnixDateTime time = AK::UnixDateTime::from_unix_time_parts( // local time
m_year.value(),
m_month.value_or(1),
m_day.value_or(1),
m_hours.value_or(0),
m_minutes.value_or(0),
m_seconds.value_or(0),
m_milliseconds.value_or(0));
double time_ms = static_cast<double>(time.milliseconds_since_epoch()); // Assume the date was given in UTC.
if (m_timezone_sign.has_value()) { // A timezone offset was specified.
if (m_timezone_hours.has_value() && (m_timezone_hours.value() > 24))
return NAN;
if (m_timezone_minutes.has_value() && (m_timezone_minutes.value() > 59))
return NAN;
if (m_timezone_seconds.has_value() && (m_timezone_seconds.value() > 59))
return NAN;
time_ms -= 1.0 * m_timezone_sign.value() * static_cast<double>( // Convert to a UTC timestamp: local timestamp minus timezone offset.
m_timezone_hours.value_or(0) * 3'600'000 + m_timezone_minutes.value_or(0) * 60'000 + m_timezone_seconds.value_or(0) * 1'000 + static_cast<u64>(m_timezone_nanoseconds.value_or(0) / 1'000'000));
} else if (!m_timezone_utc.has_value() && (!is_iso8601_date || m_hours.has_value())) {
// If a timezone offset or GMT/UTC/Z was not specified and:
// - Either this is not an ISO8601 [simplified] date
// - Or this is a date-time form [of an ISO8601 date].
// https://tc39.es/ecma262/#sec-date.parse:
// "When the UTC offset representation is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time."
time_ms = JS::utc_time(time_ms); // The date was given in local time; convert it to a UTC timestamp.
} else { // An ISO8601 date-only form
; // nop; leave timestamp as UTC
}
return JS::time_clip(time_ms);
}
ALWAYS_INLINE ErrorOr<double> parse()
{
if (TRY(maybe_iso_8601()))
return build_date(true);
// Convert the input string to uppercase only ~after~ parsing ISO8601 failed.
// This saves some time (two string copies) if parsing a ISO8601 date succeeds.
// The index stays exactly where it was before converting to uppercase.
auto str_uppercase = m_input.to_ascii_uppercase_string();
m_input = str_uppercase;
// FIXME: Two full string copies could be avoided, if to_uppercase can be done in place.
// The underlying StringView m_input protects itself from modifying its contents. Bummer.
while (!is_eof())
if (!loop())
return Error::from_string_literal("Cannot parse date components.");
if (!guess_date_from_numbers())
return Error::from_string_literal("Cannot guess date.");
return build_date();
}
DateParser(StringView const& str)
: GenericLexer(str)
{
}
public:
ALWAYS_INLINE static double parse(StringView const& str)
{
return DateParser(str).parse().value_or(NAN);
}
};