From 350fdf1e434fea91a88111b5299df455ff3610a1 Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Mon, 6 Nov 2023 20:50:11 -0500 Subject: [PATCH] LibCore+LibTimeZone: Support time zone names in Core::DateTime::parse LibCore currently cannot depend on LibTimeZone directly. All build-time code generators depend on LibCore, so there'd be a circular dependency: LibCore -> LibTimeZone -> GenerateTZData -> LibCore. So to support parsing time zone names and applying their offsets, add a couple of weakly-defined helper functions. These work similar to the way AK::String declares some methods that LibUnicode defines. Any user who wants to parse time zone names (from outside of LibCore itself) can link against LibTimeZone to receive full support. --- Meta/Lagom/CMakeLists.txt | 2 + Tests/LibCore/CMakeLists.txt | 2 + Tests/LibCore/TestLibCoreDateTime.cpp | 85 +++++++++++++++++++ Userland/Libraries/LibCore/DateTime.cpp | 26 +++++- Userland/Libraries/LibTimeZone/CMakeLists.txt | 1 + Userland/Libraries/LibTimeZone/DateTime.cpp | 38 +++++++++ Userland/Libraries/LibTimeZone/DateTime.h | 19 +++++ 7 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 Tests/LibCore/TestLibCoreDateTime.cpp create mode 100644 Userland/Libraries/LibTimeZone/DateTime.cpp create mode 100644 Userland/Libraries/LibTimeZone/DateTime.h diff --git a/Meta/Lagom/CMakeLists.txt b/Meta/Lagom/CMakeLists.txt index 1d2e7f926e..cac45a33ab 100644 --- a/Meta/Lagom/CMakeLists.txt +++ b/Meta/Lagom/CMakeLists.txt @@ -681,6 +681,8 @@ if (BUILD_LAGOM) lagom_test(../../Tests/LibCore/TestLibCorePromise.cpp LIBS LibThreading) endif() + lagom_test(../../Tests/LibCore/TestLibCoreDateTime.cpp LIBS LibTimeZone) + # RegexLibC test POSIX and contains many Serenity extensions # It is therefore not reasonable to run it on Lagom, and we only run the Regex test lagom_test(../../Tests/LibRegex/Regex.cpp LIBS LibRegex WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/../../Tests/LibRegex) diff --git a/Tests/LibCore/CMakeLists.txt b/Tests/LibCore/CMakeLists.txt index 42bc7dece4..a159bf2c21 100644 --- a/Tests/LibCore/CMakeLists.txt +++ b/Tests/LibCore/CMakeLists.txt @@ -1,5 +1,6 @@ set(TEST_SOURCES TestLibCoreArgsParser.cpp + TestLibCoreDateTime.cpp TestLibCoreDeferredInvoke.cpp TestLibCoreFilePermissionsMask.cpp TestLibCoreFileWatcher.cpp @@ -13,6 +14,7 @@ foreach(source IN LISTS TEST_SOURCES) serenity_test("${source}" LibCore) endforeach() +target_link_libraries(TestLibCoreDateTime PRIVATE LibTimeZone) target_link_libraries(TestLibCorePromise PRIVATE LibThreading) # NOTE: Required because of the LocalServer tests target_link_libraries(TestLibCoreStream PRIVATE LibThreading) diff --git a/Tests/LibCore/TestLibCoreDateTime.cpp b/Tests/LibCore/TestLibCoreDateTime.cpp new file mode 100644 index 0000000000..0fde0d3065 --- /dev/null +++ b/Tests/LibCore/TestLibCoreDateTime.cpp @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include + +class TimeZoneGuard { +public: + explicit TimeZoneGuard(StringView time_zone) + { + if (auto const* current_time_zone = getenv("TZ")) + m_time_zone = MUST(String::from_utf8({ current_time_zone, strlen(current_time_zone) })); + + update(time_zone); + } + + ~TimeZoneGuard() + { + if (m_time_zone.has_value()) + TRY_OR_FAIL(Core::System::setenv("TZ"sv, *m_time_zone, true)); + else + TRY_OR_FAIL(Core::System::unsetenv("TZ"sv)); + + tzset(); + } + + void update(StringView time_zone) + { + TRY_OR_FAIL(Core::System::setenv("TZ"sv, time_zone, true)); + tzset(); + } + +private: + Optional m_time_zone; +}; + +TEST_CASE(parse_time_zone_name) +{ + EXPECT(!Core::DateTime::parse("%Z"sv, ""sv).has_value()); + EXPECT(!Core::DateTime::parse("%Z"sv, "123"sv).has_value()); + EXPECT(!Core::DateTime::parse("%Z"sv, "notatimezone"sv).has_value()); + + auto test = [](auto format, auto time, u32 year, u32 month, u32 day, u32 hour, u32 minute) { + auto result = Core::DateTime::parse(format, time); + VERIFY(result.has_value()); + + EXPECT_EQ(year, result->year()); + EXPECT_EQ(month, result->month()); + EXPECT_EQ(day, result->day()); + EXPECT_EQ(hour, result->hour()); + EXPECT_EQ(minute, result->minute()); + }; + + TimeZoneGuard guard { "UTC"sv }; + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 UTC"sv, 2023, 01, 23, 10, 50); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 America/New_York"sv, 2023, 01, 23, 15, 50); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 Europe/Paris"sv, 2023, 01, 23, 9, 50); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 Australia/Perth"sv, 2023, 01, 23, 2, 50); + + guard.update("America/New_York"sv); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 UTC"sv, 2023, 01, 23, 5, 50); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 America/New_York"sv, 2023, 01, 23, 10, 50); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 Europe/Paris"sv, 2023, 01, 23, 4, 50); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 Australia/Perth"sv, 2023, 01, 22, 21, 50); + + guard.update("Europe/Paris"sv); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 UTC"sv, 2023, 01, 23, 11, 50); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 America/New_York"sv, 2023, 01, 23, 16, 50); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 Europe/Paris"sv, 2023, 01, 23, 10, 50); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 Australia/Perth"sv, 2023, 01, 23, 3, 50); + + guard.update("Australia/Perth"sv); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 UTC"sv, 2023, 01, 23, 18, 50); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 America/New_York"sv, 2023, 01, 23, 23, 50); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 Europe/Paris"sv, 2023, 01, 23, 17, 50); + test("%Y/%m/%d %R %Z"sv, "2023/01/23 10:50 Australia/Perth"sv, 2023, 01, 23, 10, 50); +} diff --git a/Userland/Libraries/LibCore/DateTime.cpp b/Userland/Libraries/LibCore/DateTime.cpp index 6d5b62ba92..11ecba36e2 100644 --- a/Userland/Libraries/LibCore/DateTime.cpp +++ b/Userland/Libraries/LibCore/DateTime.cpp @@ -11,11 +11,15 @@ #include #include #include +#include #include #include namespace Core { +Optional __attribute__((weak)) parse_time_zone_name(GenericLexer&) { return {}; } +void __attribute__((weak)) apply_time_zone_offset(StringView, UnixDateTime&) { } + DateTime DateTime::now() { return from_timestamp(time(nullptr)); @@ -293,6 +297,7 @@ Optional DateTime::parse(StringView format, StringView string) auto parsing_failed = false; auto tm_represents_utc_time = false; + Optional parsed_time_zone; GenericLexer string_lexer(string); @@ -500,6 +505,13 @@ Optional DateTime::parse(StringView format, StringView string) tm.tm_min += sign * minutes; break; } + case 'Z': + parsed_time_zone = parse_time_zone_name(string_lexer); + if (!parsed_time_zone.has_value()) + return {}; + + tm_represents_utc_time = true; + break; case '%': consume('%'); break; @@ -517,11 +529,17 @@ Optional DateTime::parse(StringView format, StringView string) if (!string_lexer.is_eof() || format_pos != format.length()) return {}; - // If an explicit timezone was present, the time in tm was shifted to UTC. - // Convert it to local time, since that is what `mktime` expects. + // If an explicit time zone offset was present, the time in tm was shifted to UTC. If a time zone name was present, + // the time in tm needs to be shifted to UTC. In both cases, convert the result to local time, as that is what is + // expected by `mktime`. if (tm_represents_utc_time) { - auto utc_time = timegm(&tm); - localtime_r(&utc_time, &tm); + auto utc_time = UnixDateTime::from_seconds_since_epoch(timegm(&tm)); + + if (parsed_time_zone.has_value()) + apply_time_zone_offset(*parsed_time_zone, utc_time); + + time_t utc_time_t = utc_time.seconds_since_epoch(); + localtime_r(&utc_time_t, &tm); } return DateTime::from_timestamp(mktime(&tm)); diff --git a/Userland/Libraries/LibTimeZone/CMakeLists.txt b/Userland/Libraries/LibTimeZone/CMakeLists.txt index c1cf99953b..34a47124a8 100644 --- a/Userland/Libraries/LibTimeZone/CMakeLists.txt +++ b/Userland/Libraries/LibTimeZone/CMakeLists.txt @@ -2,6 +2,7 @@ include(${SerenityOS_SOURCE_DIR}/Meta/CMake/time_zone_data.cmake) set(SOURCES ${TIME_ZONE_DATA_SOURCES} + DateTime.cpp TimeZone.cpp ) set(GENERATED_SOURCES ${CURRENT_LIB_GENERATED}) diff --git a/Userland/Libraries/LibTimeZone/DateTime.cpp b/Userland/Libraries/LibTimeZone/DateTime.cpp new file mode 100644 index 0000000000..f1ae4b14d1 --- /dev/null +++ b/Userland/Libraries/LibTimeZone/DateTime.cpp @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include + +namespace Core { + +Optional parse_time_zone_name(GenericLexer& lexer) +{ + auto start_position = lexer.tell(); + + Optional canonicalized_time_zone; + + lexer.ignore_until([&](auto) { + auto time_zone = lexer.input().substring_view(start_position, lexer.tell() - start_position + 1); + + canonicalized_time_zone = TimeZone::canonicalize_time_zone(time_zone); + return canonicalized_time_zone.has_value(); + }); + + if (canonicalized_time_zone.has_value()) + lexer.ignore(); + + return canonicalized_time_zone; +} + +void apply_time_zone_offset(StringView time_zone, UnixDateTime& time) +{ + if (auto offset = TimeZone::get_time_zone_offset(time_zone, time); offset.has_value()) + time -= Duration::from_seconds(offset->seconds); +} + +} diff --git a/Userland/Libraries/LibTimeZone/DateTime.h b/Userland/Libraries/LibTimeZone/DateTime.h new file mode 100644 index 0000000000..e1827553b5 --- /dev/null +++ b/Userland/Libraries/LibTimeZone/DateTime.h @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include + +// This file contains definitions of Core::DateTime methods which require TZDB data. +namespace Core { + +Optional parse_time_zone_name(GenericLexer&); +void apply_time_zone_offset(StringView time_zone, UnixDateTime& time); + +}