From ea32e39d68c946b277e4e547dca99752fc2dbe78 Mon Sep 17 00:00:00 2001 From: Tomasz Strejczek Date: Sat, 21 Jun 2025 17:18:45 +0200 Subject: [PATCH] AK: Add UnixDateTime::parse() method Copy parse() method from LibCore::DateTime::parse(). Augment the method to handle parsing from GMT time. Fix incorrect handling of year in '%D' format specifier. Remove all format specifiers related to time zones. Copy relevant tests and add additional ones. --- AK/Time.cpp | 259 ++++++++++++++++++++++++++++++++++++++++++ AK/Time.h | 3 + Tests/AK/TestTime.cpp | 133 ++++++++++++++++++++++ 3 files changed, 395 insertions(+) diff --git a/AK/Time.cpp b/AK/Time.cpp index dca60d24aa..8030f3acb9 100644 --- a/AK/Time.cpp +++ b/AK/Time.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,7 @@ # define localtime_r(time, tm) localtime_s(tm, time) # define gmtime_r(time, tm) gmtime_s(tm, time) # define tzname _tzname +# define timegm _mkgmtime #endif namespace AK { @@ -509,4 +511,261 @@ ByteString UnixDateTime::to_byte_string(StringView format, LocalTime local_time) return builder.to_byte_string(); } +Optional UnixDateTime::parse(StringView format, StringView string, bool from_gmt) +{ + unsigned format_pos = 0; + + struct tm tm = {}; + tm.tm_isdst = -1; + + auto parsing_failed = false; + + GenericLexer string_lexer(string); + + auto parse_number = [&] { + auto result = string_lexer.consume_decimal_integer(); + if (result.is_error()) { + parsing_failed = true; + return 0; + } + return result.value(); + }; + + auto consume = [&](char c) { + if (!string_lexer.consume_specific(c)) + parsing_failed = true; + }; + + auto consume_specific_ascii_case_insensitive = [&](StringView name) { + auto next_string = string_lexer.peek_string(name.length()); + if (next_string.has_value() && next_string->equals_ignoring_ascii_case(name)) { + string_lexer.consume(name.length()); + return true; + } + return false; + }; + + while (format_pos < format.length() && !string_lexer.is_eof()) { + if (format[format_pos] != '%') { + consume(format[format_pos]); + format_pos++; + continue; + } + + format_pos++; + if (format_pos == format.length()) + return {}; + + switch (format[format_pos]) { + case 'a': { + auto wday = 0; + for (auto name : short_day_names) { + if (consume_specific_ascii_case_insensitive(name)) { + tm.tm_wday = wday; + break; + } + ++wday; + } + if (wday == 7) + return {}; + break; + } + case 'A': { + auto wday = 0; + for (auto name : long_day_names) { + if (consume_specific_ascii_case_insensitive(name)) { + tm.tm_wday = wday; + break; + } + ++wday; + } + if (wday == 7) + return {}; + break; + } + case 'h': + case 'b': { + auto mon = 0; + for (auto name : short_month_names) { + if (consume_specific_ascii_case_insensitive(name)) { + tm.tm_mon = mon; + break; + } + ++mon; + } + if (mon == 12) + return {}; + break; + } + case 'B': { + auto mon = 0; + for (auto name : long_month_names) { + if (consume_specific_ascii_case_insensitive(name)) { + tm.tm_mon = mon; + break; + } + ++mon; + } + if (mon == 12) + return {}; + break; + } + case 'C': { + int num = parse_number(); + tm.tm_year = (num - 19) * 100 + (tm.tm_year % 100); + break; + } + case 'd': + tm.tm_mday = parse_number(); + break; + case 'D': { + int mon = parse_number(); + consume('/'); + int day = parse_number(); + consume('/'); + int year = parse_number(); + tm.tm_mon = mon - 1; + tm.tm_mday = day; + tm.tm_year = year > 1900 ? year - 1900 : (year <= 99 && year > 69 ? year : 100 + year); + break; + } + case 'e': + tm.tm_mday = parse_number(); + break; + case 'H': + tm.tm_hour = parse_number(); + break; + case 'I': { + int num = parse_number(); + tm.tm_hour = num % 12; + break; + } + case 'j': + // a little trickery here... we can get mktime() to figure out mon and mday using out of range values. + // yday is not used so setting it is pointless. + tm.tm_mday = parse_number(); + tm.tm_mon = 0; + (void)mktime(&tm); + break; + case 'm': { + int num = parse_number(); + tm.tm_mon = num - 1; + break; + } + case 'M': + tm.tm_min = parse_number(); + break; + case 'n': + case 't': + string_lexer.consume_while(is_ascii_space); + break; + case 'r': + case 'p': { + auto ampm = string_lexer.consume(2); + if (ampm == "PM") { + if (tm.tm_hour < 12) + tm.tm_hour += 12; + } else if (ampm != "AM") { + return {}; + } + break; + } + case 'R': + tm.tm_hour = parse_number(); + consume(':'); + tm.tm_min = parse_number(); + break; + case 'S': + tm.tm_sec = parse_number(); + break; + case 'T': + tm.tm_hour = parse_number(); + consume(':'); + tm.tm_min = parse_number(); + consume(':'); + tm.tm_sec = parse_number(); + break; + case 'w': + tm.tm_wday = parse_number(); + break; + case 'y': { + int year = parse_number(); + tm.tm_year = year <= 99 && year > 69 ? 1900 + year : 2000 + year; + break; + } + case 'Y': { + int year = parse_number(); + tm.tm_year = year - 1900; + break; + } + case 'x': { + auto hours = parse_number(); + int minutes; + if (string_lexer.consume_specific(':')) { + minutes = parse_number(); + } else { + minutes = hours % 100; + hours = hours / 100; + } + + tm.tm_hour -= hours; + tm.tm_min -= minutes; + break; + } + case 'X': { + if (!string_lexer.consume_specific('.')) + return {}; + auto discarded = parse_number(); + (void)discarded; // NOTE: the tm structure does not support sub second precision, so drop this value. + break; + } + case '+': { + Optional next_format_character; + + if (format_pos + 1 < format.length()) { + next_format_character = format[format_pos + 1]; + + // Disallow another formatter directly after %+. This is to avoid ambiguity when parsing a string like + // "ignoreJan" with "%+%b", as it would be non-trivial to know that where the %b field begins. + if (next_format_character == '%') + return {}; + } + + auto discarded = string_lexer.consume_until([&](auto ch) { return ch == next_format_character; }); + if (discarded.is_empty()) + return {}; + + break; + } + case '%': + consume('%'); + break; + default: + parsing_failed = true; + break; + } + + if (parsing_failed) + return {}; + + format_pos++; + } + + if (!string_lexer.is_eof() || format_pos != format.length()) + return {}; + + if (from_gmt) { + // When from_gmt is true, the parsed time is in GMT and needs to be converted to Unix time + tm.tm_isdst = 0; // GMT doesn't have daylight saving time + auto gmt_time = timegm(&tm); + if (gmt_time == -1) + return {}; + return UnixDateTime::from_seconds_since_epoch(gmt_time); + } + + return UnixDateTime::from_unix_time_parts( + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec, 0); +} + } diff --git a/AK/Time.h b/AK/Time.h index f45748b97d..477bde70d0 100644 --- a/AK/Time.h +++ b/AK/Time.h @@ -478,6 +478,9 @@ public: ErrorOr to_string(StringView format = "%Y-%m-%d %H:%M:%S"sv, LocalTime = LocalTime::Yes) const; Utf16String to_utf16_string(StringView format = "%Y-%m-%d %H:%M:%S"sv, LocalTime = LocalTime::Yes) const; ByteString to_byte_string(StringView format = "%Y-%m-%d %H:%M:%S"sv, LocalTime = LocalTime::Yes) const; + // Parses a string in the given format. Does not support time zone-related format specifiers. + // If 'from_gmt' is true, the string is parsed as a GMT time, otherwise it is parsed as a local time. + static Optional parse(StringView format, StringView string, bool from_gmt = false); // Offsetting a UNIX time by a duration yields another UNIX time. constexpr UnixDateTime operator+(Duration const& other) const { return UnixDateTime { m_offset + other }; } diff --git a/Tests/AK/TestTime.cpp b/Tests/AK/TestTime.cpp index e307fa7937..33c32fb786 100644 --- a/Tests/AK/TestTime.cpp +++ b/Tests/AK/TestTime.cpp @@ -8,6 +8,11 @@ #include +#ifdef AK_OS_WINDOWS +# include +# define gmtime_r(time, tm) gmtime_s(tm, time) +#endif + using AK::Duration; #if (defined(__TIMESIZE) && __TIMESIZE < 64) || defined(AK_OS_WINDOWS) // NOTE: See AK/Time.h, on Windows we hardcode to long's for timeval @@ -757,3 +762,131 @@ TEST_CASE(time_to_string) test("%t"sv, "\t"sv, 2023, 1, 1, 0, 0, 0); test("%%"sv, "%"sv, 2023, 1, 1, 0, 0, 0); } + +TEST_CASE(parse_time) +{ + auto test = [](auto format, auto time, int year, int month, int day, int hour, int minute, int second = 0) { + auto result = AK::UnixDateTime::parse(format, time); + VERIFY(result.has_value()); + + auto result_time = result.value().to_timespec(); + struct tm tm; +#ifdef AK_OS_WINDOWS + VERIFY(gmtime_r(&result_time.tv_sec, &tm) == 0); +#else + VERIFY(gmtime_r(&result_time.tv_sec, &tm) != nullptr); +#endif + + EXPECT_EQ(year, tm.tm_year + 1900); + EXPECT_EQ(month, tm.tm_mon + 1); + EXPECT_EQ(day, tm.tm_mday); + EXPECT_EQ(hour, tm.tm_hour); + EXPECT_EQ(minute, tm.tm_min); + EXPECT_EQ(second, tm.tm_sec); + }; + + test("%d-%m-%Y %H:%M"sv, "05-01-2023 00:00"sv, 2023, 1, 5, 0, 0); + test("%d-%m-%Y %H:%M GMT"sv, "05-01-2023 00:00 GMT"sv, 2023, 1, 5, 0, 0); + test("%Y/%m/%d %R"sv, "2023/01/23 10:50"sv, 2023, 1, 23, 10, 50); + test("%Y-%m-%d %H:%M"sv, "1999-12-31 23:59"sv, 1999, 12, 31, 23, 59); + test("%Y/%m/%d %R"sv, "1970/01/01 00:00"sv, 1970, 1, 1, 0, 0); + test("%Y/%m/%d %R"sv, "2000/02/29 12:34"sv, 2000, 2, 29, 12, 34); // Leap year + + // %T: full time with seconds + test("%Y-%m-%d %T"sv, "2023-01-23 10:50:15"sv, 2023, 1, 23, 10, 50, 15); + // %S: seconds + test("%Y-%m-%d %H:%M:%S"sv, "2023-01-23 10:50:42"sv, 2023, 1, 23, 10, 50, 42); + // %I: 12-hour clock, %p: AM/PM + test("%Y-%m-%d %I:%M %p"sv, "2023-01-23 03:21 PM"sv, 2023, 1, 23, 15, 21, 0); + test("%Y-%m-%d %I:%M %p"sv, "2023-01-23 12:01 AM"sv, 2023, 1, 23, 0, 1, 0); + // %D: shortcut for %m/%d/%y + test("%D %H:%M:%S"sv, "01/23/23 10:50:59"sv, 2023, 1, 23, 10, 50, 59); + // %y: two-digit year, %C: century + test("%y %C %m %d %H:%M:%S"sv, "23 20 01 23 10:50:11"sv, 2023, 1, 23, 10, 50, 11); + // %a/%A: short/long weekday name (parsing ignores names, but test for correct date) + test("%Y-%m-%d %a"sv, "2023-01-23 Mon"sv, 2023, 1, 23, 0, 0, 0); + test("%Y-%m-%d %A"sv, "2023-01-23 Monday"sv, 2023, 1, 23, 0, 0, 0); + // %b/%B: short/long month name (parsing ignores names, but test for correct date) + test("%d %b %Y"sv, "05 Jan 2023"sv, 2023, 1, 5, 0, 0, 0); + test("%d %B %Y"sv, "05 January 2023"sv, 2023, 1, 5, 0, 0, 0); + // %j: day of year + test("%Y %j %H:%M:%S"sv, "2023 023 10:50:12"sv, 2023, 1, 23, 10, 50, 12); + // %w: weekday number (0=Sunday) + test("%Y-%m-%d %w %H:%M:%S"sv, "2023-01-23 1 10:50:13"sv, 2023, 1, 23, 10, 50, 13); + // %n: newline, %t: tab, %%: literal % + test("%Y-%m-%d%n%H:%M:%S"sv, "2023-01-23\n10:50:14"sv, 2023, 1, 23, 10, 50, 14); + test("%Y-%m-%d%t%H:%M:%S"sv, "2023-01-23\t10:50:15"sv, 2023, 1, 23, 10, 50, 15); + test("%Y-%m-%d %% %H:%M:%S"sv, "2023-01-23 % 10:50:16"sv, 2023, 1, 23, 10, 50, 16); +} + +TEST_CASE(parse_wildcard_characters) +{ + EXPECT(!AK::UnixDateTime::parse("%+"sv, ""sv).has_value()); + EXPECT(!AK::UnixDateTime::parse("foo%+"sv, "foo"sv).has_value()); + EXPECT(!AK::UnixDateTime::parse("[%*]"sv, "[foo"sv).has_value()); + EXPECT(!AK::UnixDateTime::parse("[%*]"sv, "foo]"sv).has_value()); + EXPECT(!AK::UnixDateTime::parse("%+%b"sv, "fooJan"sv).has_value()); + + auto test = [](auto format, auto time, int year, int month, int day) { + auto result = AK::UnixDateTime::parse(format, time); + VERIFY(result.has_value()); + + auto result_time = result.value().to_timespec(); + struct tm tm; +#ifdef AK_OS_WINDOWS + VERIFY(gmtime_r(&result_time.tv_sec, &tm) == 0); +#else + VERIFY(gmtime_r(&result_time.tv_sec, &tm) != nullptr); +#endif + + EXPECT_EQ(year, tm.tm_year + 1900); + EXPECT_EQ(month, tm.tm_mon + 1); + EXPECT_EQ(day, tm.tm_mday); + }; + + test("%Y %+ %m %d"sv, "2023 whf 01 23"sv, 2023, 01, 23); + test("%Y %m %d %+"sv, "2023 01 23 whf"sv, 2023, 01, 23); + test("%Y [%+] %m %d"sv, "2023 [well hello friends!] 01 23"sv, 2023, 01, 23); + test("%Y %m %d [%+]"sv, "2023 01 23 [well hello friends!]"sv, 2023, 01, 23); +} + +TEST_CASE(parse_time_from_gmt) +{ + // Test parsing with from_gmt = true (GMT time) + auto gmt_result = AK::UnixDateTime::parse("%Y-%m-%d %H:%M:%S"sv, + "2023-01-15 12:00:00"sv, true); + VERIFY(gmt_result.has_value()); + + // Parse a known GMT time and verify the timestamp + auto test_gmt = AK::UnixDateTime::parse("%Y-%m-%d %H:%M:%S"sv, + "1970-01-01 00:00:00"sv, true); + VERIFY(test_gmt.has_value()); + EXPECT_EQ(test_gmt.value().seconds_since_epoch(), 0); + + // Test another GMT time + auto test_gmt2 = AK::UnixDateTime::parse("%Y-%m-%d %H:%M:%S"sv, + "1970-01-01 01:00:00"sv, true); + VERIFY(test_gmt2.has_value()); + EXPECT_EQ(test_gmt2.value().seconds_since_epoch(), 3600); + + // Test with date components + auto test_gmt3 = AK::UnixDateTime::parse("%Y-%m-%d %H:%M:%S"sv, + "2023-06-15 18:30:45"sv, true); + VERIFY(test_gmt3.has_value()); + + // Verify the parsed time by converting back to tm structure + auto result_time = test_gmt3.value().to_timespec(); + struct tm tm; +#ifdef AK_OS_WINDOWS + VERIFY(gmtime_r(&result_time.tv_sec, &tm) == 0); +#else + VERIFY(gmtime_r(&result_time.tv_sec, &tm) != nullptr); +#endif + + EXPECT_EQ(tm.tm_year + 1900, 2023); + EXPECT_EQ(tm.tm_mon + 1, 6); + EXPECT_EQ(tm.tm_mday, 15); + EXPECT_EQ(tm.tm_hour, 18); + EXPECT_EQ(tm.tm_min, 30); + EXPECT_EQ(tm.tm_sec, 45); +}