Thread UTF-16 string input through JSON, script parsing, Date parsing, Intl option parsing, Temporal parsing, and the helper library boundaries that feed those parsers. Preserve ASCII fast paths where the source data is known to be ASCII.
994 lines
33 KiB
C++
994 lines
33 KiB
C++
/*
|
|
* Copyright (c) 2025, Manuel Zahariev <manuel@duck.com>
|
|
*
|
|
* SPDX-License-Identifier: BSD-2-Clause
|
|
*/
|
|
|
|
#pragma once
|
|
|
|
#include <AK/AllOf.h>
|
|
#include <AK/CharacterTypes.h>
|
|
#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>
|
|
|
|
// 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 Utf16GenericLexer {
|
|
public:
|
|
ALWAYS_INLINE static double parse(Utf16View string)
|
|
{
|
|
if (!all_of(string, is_ascii))
|
|
return NAN;
|
|
return DateParser(string).parse().value_or(NAN);
|
|
}
|
|
|
|
private:
|
|
explicit DateParser(Utf16View string)
|
|
: Utf16GenericLexer(string)
|
|
{
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
template<Unsigned T>
|
|
struct ParsedNumber {
|
|
T number { 0 };
|
|
size_t digits { 0 };
|
|
};
|
|
|
|
// 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<Unsigned T, size_t MaxLength>
|
|
ALWAYS_INLINE ParsedNumber<T> read_number()
|
|
{
|
|
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.");
|
|
|
|
ParsedNumber<T> result;
|
|
|
|
for (; result.digits < MaxLength; ++result.digits) {
|
|
if (is_eof() || !next_is(is_ascii_digit))
|
|
return result;
|
|
result.number *= 10;
|
|
result.number += consume() - '0';
|
|
}
|
|
|
|
ignore_while(is_ascii_digit);
|
|
return result;
|
|
}
|
|
|
|
ALWAYS_INLINE bool guess_date_from_numbers() // Guess an ambiguous date, like 1/1/1.
|
|
{
|
|
if (m_year.has_value() && m_month.has_value() && m_day.has_value())
|
|
return m_numbers.is_empty();
|
|
|
|
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 ' ':
|
|
m_previous_separator_was_space = true;
|
|
ignore();
|
|
return true;
|
|
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.
|
|
m_previous_separator_was_space = false;
|
|
ignore(); // Ignore punctuation.
|
|
return true;
|
|
case '(':
|
|
ignore_until(')'); // Consume time zone name (Anything in brackets).
|
|
ignore();
|
|
ignore_while(is_ascii_space);
|
|
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(is_ascii_space);
|
|
|
|
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;
|
|
}
|
|
|
|
// H[H]:MM[:SS[.mss[...]]][ ][AM|PM] At this point, H[H]: has already been read.
|
|
ALWAYS_INLINE bool maybe_time(
|
|
ParsedNumber<u32> const& hours,
|
|
bool allow_single_digit_minutes_and_seconds = false)
|
|
{
|
|
if (m_hours.has_value())
|
|
return false; // Time has already been read.
|
|
|
|
if (hours.digits > 2)
|
|
return false; // 123:
|
|
if (hours.number > 24)
|
|
return false;
|
|
m_hours = hours.number; // No precision loss during the conversion u32 -> u8 since the hours has at most 2 digits.
|
|
|
|
auto [minutes, minutes_digits] = read_number<u32, 3>();
|
|
if (minutes_digits != 2 && !(allow_single_digit_minutes_and_seconds && minutes_digits == 1))
|
|
return false; // 12:345 or 12:3
|
|
if (minutes > 59)
|
|
return false;
|
|
m_minutes = minutes;
|
|
|
|
if (consume_specific('.'))
|
|
return false; // 12:34.
|
|
if (!consume_specific(':'))
|
|
return true;
|
|
|
|
auto [seconds, seconds_digits] = read_number<u32, 3>();
|
|
if (seconds_digits != 2 && !(allow_single_digit_minutes_and_seconds && seconds_digits == 1))
|
|
return false; // 12:34:567 or 12:34:5
|
|
if (seconds > 59)
|
|
return false;
|
|
m_seconds = seconds;
|
|
|
|
if (!consume_specific('.'))
|
|
return true;
|
|
|
|
auto [milliseconds, milliseconds_digits] = read_number<u32, 3>();
|
|
switch (milliseconds_digits) {
|
|
case 0:
|
|
return false; // 12:34:56.
|
|
case 1:
|
|
m_milliseconds = milliseconds * 100;
|
|
break;
|
|
case 2:
|
|
m_milliseconds = milliseconds * 10;
|
|
break;
|
|
case 3:
|
|
m_milliseconds = milliseconds;
|
|
break;
|
|
default:
|
|
VERIFY_NOT_REACHED();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
ALWAYS_INLINE bool maybe_number()
|
|
{
|
|
auto const previous_separator_was_space = m_previous_separator_was_space;
|
|
m_previous_separator_was_space = false;
|
|
|
|
auto const number = read_number<u32, 7>();
|
|
VERIFY(number.digits > 0);
|
|
|
|
if (number.digits > 6)
|
|
return false; // 1234567
|
|
|
|
if (consume_specific(':')) {
|
|
if (!maybe_time(number, m_date_is_iso_like && previous_separator_was_space))
|
|
return false;
|
|
if (!maybe_ampm())
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
m_numbers.append(number.number);
|
|
|
|
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.digits) {
|
|
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.number); // 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.number; // 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(ParsedNumber<u32> 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.digits) {
|
|
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.number;
|
|
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.number;
|
|
m_timezone_hours = tz_hhmmss / 10000;
|
|
m_timezone_minutes = tz_hhmmss % 10000 / 100;
|
|
m_timezone_seconds = tz_hhmmss % 100;
|
|
return true;
|
|
}
|
|
default:
|
|
return false;
|
|
}
|
|
|
|
auto const timezone_offset_requires_colon = iso_8601_format && number.digits == 2;
|
|
m_timezone_hours = number.number; // Guaranteed to be a 1- or 2-digit number.
|
|
|
|
if (!consume_specific(':'))
|
|
return !timezone_offset_requires_colon; // +H[H] "military" time offset
|
|
|
|
// Timezone with colon
|
|
auto [minutes, minutes_digits] = read_number<u16, 3>();
|
|
if (minutes_digits != 2)
|
|
return false;
|
|
m_timezone_minutes = minutes;
|
|
|
|
if (!consume_specific(':'))
|
|
return true;
|
|
|
|
auto [seconds, seconds_digits] = read_number<u16, 3>();
|
|
if (seconds_digits != 2)
|
|
return false;
|
|
m_timezone_seconds = seconds;
|
|
|
|
if (!consume_specific('.'))
|
|
return true;
|
|
|
|
auto [nanoseconds, nanoseconds_digits] = read_number<u16, 3>();
|
|
if (nanoseconds_digits == 0)
|
|
return false;
|
|
if (nanoseconds_digits > 9)
|
|
return false;
|
|
|
|
m_timezone_nanoseconds = nanoseconds * pow10(9 - nanoseconds_digits);
|
|
|
|
return true;
|
|
}
|
|
|
|
ALWAYS_INLINE bool separator() // Ignore space and Firefox punctuation.
|
|
{
|
|
if (is_eof())
|
|
return true;
|
|
switch (peek()) {
|
|
case ' ':
|
|
m_previous_separator_was_space = true;
|
|
ignore();
|
|
return true;
|
|
case ',':
|
|
case '.':
|
|
case '/':
|
|
case '-':
|
|
m_previous_separator_was_space = false;
|
|
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(is_ascii_space).is_empty();
|
|
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(is_ascii_alpha); // ... 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".
|
|
{
|
|
consume_while(is_ascii_alpha);
|
|
// 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.digits) {
|
|
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.number);
|
|
return false;
|
|
case 4: // Four-digit year number.
|
|
m_year = number.number;
|
|
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, digits] = read_number<u32, 7>();
|
|
|
|
switch (digits) {
|
|
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); // 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;
|
|
|
|
// "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;
|
|
|
|
m_date_is_iso_like = true;
|
|
|
|
auto [month, month_digits] = 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, month_digits }))
|
|
return false;
|
|
return Error::from_string_literal("Found something that looks like time, but is not.");
|
|
}
|
|
|
|
switch (month_digits) {
|
|
case 0:
|
|
return false; // Not ISO8601 date format. Continue reading.
|
|
case 1:
|
|
m_month = month;
|
|
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;
|
|
|
|
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, day_digits] = read_number<u16, 3>();
|
|
|
|
switch (day_digits) {
|
|
case 0:
|
|
return false; // Not ISO8601 date format. Continue reading.
|
|
case 1:
|
|
m_day = day;
|
|
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;
|
|
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, hours_digits] = read_number<u16, 3>();
|
|
|
|
if (!consume_specific(':')) // 12
|
|
return Error::from_string_literal("Well specified time needs minutes.");
|
|
|
|
if (hours_digits != 2)
|
|
return Error::from_string_literal("Hours: invalid length.");
|
|
|
|
if (!maybe_time({ hours, hours_digits })) // 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();
|
|
m_input = str_uppercase.utf16_view();
|
|
// FIXME: Two full string copies could be avoided, if to_uppercase can be done in place.
|
|
// The underlying Utf16View 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();
|
|
}
|
|
|
|
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;
|
|
|
|
bool m_date_is_iso_like { false };
|
|
bool m_previous_separator_was_space { false };
|
|
};
|