Browse Source

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.
Timothy Flynn 1 year ago
parent
commit
350fdf1e43

+ 2 - 0
Meta/Lagom/CMakeLists.txt

@@ -681,6 +681,8 @@ if (BUILD_LAGOM)
             lagom_test(../../Tests/LibCore/TestLibCorePromise.cpp LIBS LibThreading)
             lagom_test(../../Tests/LibCore/TestLibCorePromise.cpp LIBS LibThreading)
         endif()
         endif()
 
 
+        lagom_test(../../Tests/LibCore/TestLibCoreDateTime.cpp LIBS LibTimeZone)
+
         # RegexLibC test POSIX <regex.h> and contains many Serenity extensions
         # RegexLibC test POSIX <regex.h> and contains many Serenity extensions
         # It is therefore not reasonable to run it on Lagom, and we only run the Regex test
         # 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)
         lagom_test(../../Tests/LibRegex/Regex.cpp LIBS LibRegex WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/../../Tests/LibRegex)

+ 2 - 0
Tests/LibCore/CMakeLists.txt

@@ -1,5 +1,6 @@
 set(TEST_SOURCES
 set(TEST_SOURCES
     TestLibCoreArgsParser.cpp
     TestLibCoreArgsParser.cpp
+    TestLibCoreDateTime.cpp
     TestLibCoreDeferredInvoke.cpp
     TestLibCoreDeferredInvoke.cpp
     TestLibCoreFilePermissionsMask.cpp
     TestLibCoreFilePermissionsMask.cpp
     TestLibCoreFileWatcher.cpp
     TestLibCoreFileWatcher.cpp
@@ -13,6 +14,7 @@ foreach(source IN LISTS TEST_SOURCES)
     serenity_test("${source}" LibCore)
     serenity_test("${source}" LibCore)
 endforeach()
 endforeach()
 
 
+target_link_libraries(TestLibCoreDateTime PRIVATE LibTimeZone)
 target_link_libraries(TestLibCorePromise PRIVATE LibThreading)
 target_link_libraries(TestLibCorePromise PRIVATE LibThreading)
 # NOTE: Required because of the LocalServer tests
 # NOTE: Required because of the LocalServer tests
 target_link_libraries(TestLibCoreStream PRIVATE LibThreading)
 target_link_libraries(TestLibCoreStream PRIVATE LibThreading)

+ 85 - 0
Tests/LibCore/TestLibCoreDateTime.cpp

@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2023, Tim Flynn <trflynn89@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include <AK/Optional.h>
+#include <AK/String.h>
+#include <AK/StringView.h>
+#include <LibCore/DateTime.h>
+#include <LibCore/System.h>
+#include <LibTest/TestCase.h>
+#include <time.h>
+
+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<String> 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);
+}

+ 22 - 4
Userland/Libraries/LibCore/DateTime.cpp

@@ -11,11 +11,15 @@
 #include <AK/StringBuilder.h>
 #include <AK/StringBuilder.h>
 #include <AK/Time.h>
 #include <AK/Time.h>
 #include <LibCore/DateTime.h>
 #include <LibCore/DateTime.h>
+#include <LibTimeZone/DateTime.h>
 #include <errno.h>
 #include <errno.h>
 #include <time.h>
 #include <time.h>
 
 
 namespace Core {
 namespace Core {
 
 
+Optional<StringView> __attribute__((weak)) parse_time_zone_name(GenericLexer&) { return {}; }
+void __attribute__((weak)) apply_time_zone_offset(StringView, UnixDateTime&) { }
+
 DateTime DateTime::now()
 DateTime DateTime::now()
 {
 {
     return from_timestamp(time(nullptr));
     return from_timestamp(time(nullptr));
@@ -293,6 +297,7 @@ Optional<DateTime> DateTime::parse(StringView format, StringView string)
 
 
     auto parsing_failed = false;
     auto parsing_failed = false;
     auto tm_represents_utc_time = false;
     auto tm_represents_utc_time = false;
+    Optional<StringView> parsed_time_zone;
 
 
     GenericLexer string_lexer(string);
     GenericLexer string_lexer(string);
 
 
@@ -500,6 +505,13 @@ Optional<DateTime> DateTime::parse(StringView format, StringView string)
             tm.tm_min += sign * minutes;
             tm.tm_min += sign * minutes;
             break;
             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 '%':
         case '%':
             consume('%');
             consume('%');
             break;
             break;
@@ -517,11 +529,17 @@ Optional<DateTime> DateTime::parse(StringView format, StringView string)
     if (!string_lexer.is_eof() || format_pos != format.length())
     if (!string_lexer.is_eof() || format_pos != format.length())
         return {};
         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) {
     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));
     return DateTime::from_timestamp(mktime(&tm));

+ 1 - 0
Userland/Libraries/LibTimeZone/CMakeLists.txt

@@ -2,6 +2,7 @@ include(${SerenityOS_SOURCE_DIR}/Meta/CMake/time_zone_data.cmake)
 
 
 set(SOURCES
 set(SOURCES
     ${TIME_ZONE_DATA_SOURCES}
     ${TIME_ZONE_DATA_SOURCES}
+    DateTime.cpp
     TimeZone.cpp
     TimeZone.cpp
 )
 )
 set(GENERATED_SOURCES ${CURRENT_LIB_GENERATED})
 set(GENERATED_SOURCES ${CURRENT_LIB_GENERATED})

+ 38 - 0
Userland/Libraries/LibTimeZone/DateTime.cpp

@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2023, Tim Flynn <trflynn89@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include <AK/GenericLexer.h>
+#include <LibTimeZone/DateTime.h>
+#include <LibTimeZone/TimeZone.h>
+
+namespace Core {
+
+Optional<StringView> parse_time_zone_name(GenericLexer& lexer)
+{
+    auto start_position = lexer.tell();
+
+    Optional<StringView> 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);
+}
+
+}

+ 19 - 0
Userland/Libraries/LibTimeZone/DateTime.h

@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2023, Tim Flynn <trflynn89@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include <AK/Optional.h>
+#include <AK/StringView.h>
+#include <AK/Time.h>
+
+// This file contains definitions of Core::DateTime methods which require TZDB data.
+namespace Core {
+
+Optional<StringView> parse_time_zone_name(GenericLexer&);
+void apply_time_zone_offset(StringView time_zone, UnixDateTime& time);
+
+}