From 3a0ea83fef280214b6b6e15ece2901d9daf36f25 Mon Sep 17 00:00:00 2001 From: Tina Liu Date: Fri, 12 Dec 2025 16:42:44 +0900 Subject: [PATCH 1/4] Add tests and benchmark --- .../BenchmarkTimeZone.swift | 30 ++++++++++ .../TimeZoneTests.swift | 57 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/Benchmarks/Benchmarks/Internationalization/BenchmarkTimeZone.swift b/Benchmarks/Benchmarks/Internationalization/BenchmarkTimeZone.swift index 85f372750..c69dd9dc6 100644 --- a/Benchmarks/Benchmarks/Internationalization/BenchmarkTimeZone.swift +++ b/Benchmarks/Benchmarks/Internationalization/BenchmarkTimeZone.swift @@ -71,6 +71,36 @@ func timeZoneBenchmarks() { blackHole(t) } } + + guard let gmtPlus8 = TimeZone(identifier: "GMT+8") else { + fatalError("unexpected failure when creating time zone") + } + + let locale = Locale(identifier: "jp_JP") + + Benchmark("GMTOffsetTimeZoneAPI", configuration: .init(scalingFactor: .mega)) { benchmark in + for d in testDates { + let secondsFromGMT = gmtPlus8.secondsFromGMT(for: d) + blackHole(secondsFromGMT) + + let abbreviation = gmtPlus8.abbreviation(for: d) + blackHole(abbreviation) + + let isDST = gmtPlus8.isDaylightSavingTime(for: d) + blackHole(isDST) + + let nextDST = gmtPlus8.nextDaylightSavingTimeTransition(after: d) + blackHole(nextDST) + } + } + + Benchmark("GMTOffsetTimeZone-localizedNames", configuration: .init(scalingFactor: .mega)) { benchmark in + for style in [TimeZone.NameStyle.generic, .standard, .shortGeneric, .shortStandard, .daylightSaving, .shortDaylightSaving] { + let localizedName = gmtPlus8.localizedName(for: style, locale: locale) + blackHole(localizedName) + } + } + } diff --git a/Tests/FoundationInternationalizationTests/TimeZoneTests.swift b/Tests/FoundationInternationalizationTests/TimeZoneTests.swift index e21cb4ea8..3241cf0a2 100644 --- a/Tests/FoundationInternationalizationTests/TimeZoneTests.swift +++ b/Tests/FoundationInternationalizationTests/TimeZoneTests.swift @@ -138,6 +138,63 @@ private struct TimeZoneTests { try testAbbreviation("UTC+0900", 32400, "GMT+0900") } + @Test func timeZoneGMTOffset() throws { + func testName(_ name: String, _ expectedOffset: Int, sourceLocation: SourceLocation = #_sourceLocation) throws { + let tz = try #require(TimeZone(identifier: name)) + let secondsFromGMT = tz.secondsFromGMT() + #expect(secondsFromGMT == expectedOffset) + #expect(tz.isDaylightSavingTime() == false) + #expect(tz.nextDaylightSavingTimeTransition == nil) + } + + try testName("GMT+8", 8*3600) + try testName("GMT+08", 8*3600) + try testName("GMT+0800", 8*3600) + try testName("GMT+08:00", 8*3600) + try testName("GMT+8:00", 8*3600) + try testName("UTC+9", 9*3600) + try testName("UTC+09", 9*3600) + try testName("UTC+0900", 9*3600) + try testName("UTC+09:00", 9*3600) + try testName("UTC+9:00", 9*3600) + } + + @Test(arguments: ["en_001", "en_US", "ja_JP"]) + func timeZoneGMTOffset_localizedNames(localeIdentifier: String) throws { + let locale = Locale(identifier: localeIdentifier) + func testNames( + _ names: [String], + _ expectedStandardName: String, + _ expectedShortStandardName: String, + _ expectedDaylightSavingName: String, + _ expectedShortDaylightSavingName: String, + _ expectedGenericName: String, + _ expectedShortGenericName: String, + sourceLocation: SourceLocation = #_sourceLocation) throws { + for name in names { + let tz = try #require(TimeZone(identifier: name)) + let standardName = tz.localizedName(for: .standard, locale: locale) + let shortStandardName = tz.localizedName(for: .shortStandard, locale: locale) + let daylightSavingName = tz.localizedName(for: .daylightSaving, locale: locale) + let shortDaylightSavingName = tz.localizedName(for: .shortDaylightSaving, locale: locale) + let generic = tz.localizedName(for: .generic, locale: locale) + let shortGeneric = tz.localizedName(for: .shortGeneric, locale: locale) + + #expect(expectedStandardName == standardName) + #expect(expectedShortStandardName == shortStandardName) + #expect(expectedDaylightSavingName == daylightSavingName) + #expect(expectedShortDaylightSavingName == shortDaylightSavingName) + #expect(expectedGenericName == generic) + #expect(expectedShortGenericName == shortGeneric) + } + } + + try testNames(["GMT+8", "GMT+08", "GMT+0800", "GMT+08:00", "GMT+8:00"], + "GMT+08:00", "GMT+8", "GMT+08:00", "GMT+8", "GMT+08:00", "GMT+8") + try testNames(["UTC+9", "UTC+09", "UTC+0900", "UTC+09:00", "UTC+9:00"], + "GMT+09:00", "GMT+9", "GMT+09:00", "GMT+9", "GMT+09:00", "GMT+9") + } + @Test func secondsFromGMT_RemoteDates() { let date = Date(timeIntervalSinceReferenceDate: -5001243627) // "1842-07-09T05:39:33+0000" let europeRome = TimeZone(identifier: "Europe/Rome")! From 7d30deab06c200841715aa38054aa815b8e51d7f Mon Sep 17 00:00:00 2001 From: Tina Liu Date: Fri, 12 Dec 2025 17:13:10 +0900 Subject: [PATCH 2/4] Use `_TimeZoneGMTICU` for timezones whose identifier take the form of "GMT/UTC+" such as `"GMT+8"` for performance reasons. Also update to uatimezone API for localizing names. 166054881 --- .../TimeZone/TimeZone_Cache.swift | 4 +++ .../TimeZone/TimeZone_GMT.swift | 7 ++++- .../TimeZone/TimeZone_GMTICU.swift | 31 +++++++++---------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/Sources/FoundationEssentials/TimeZone/TimeZone_Cache.swift b/Sources/FoundationEssentials/TimeZone/TimeZone_Cache.swift index 7bb5288b2..4f4646278 100644 --- a/Sources/FoundationEssentials/TimeZone/TimeZone_Cache.swift +++ b/Sources/FoundationEssentials/TimeZone/TimeZone_Cache.swift @@ -227,6 +227,10 @@ struct TimeZoneCache : Sendable, ~Copyable { return offsetFixed(0) } else if let cached = fixedTimeZones[identifier] { return cached + } else if let innerTZ = _timeZoneGMTClass().init(identifier: identifier) { + // Identifier takes a form of GMT offset such as "GMT+8" + fixedTimeZones[identifier] = innerTZ + return innerTZ } else { if let innerTz = _timeZoneICUClass()?.init(identifier: identifier) { fixedTimeZones[identifier] = innerTz diff --git a/Sources/FoundationEssentials/TimeZone/TimeZone_GMT.swift b/Sources/FoundationEssentials/TimeZone/TimeZone_GMT.swift index 2f0d64869..89d072aa4 100644 --- a/Sources/FoundationEssentials/TimeZone/TimeZone_GMT.swift +++ b/Sources/FoundationEssentials/TimeZone/TimeZone_GMT.swift @@ -15,7 +15,12 @@ package final class _TimeZoneGMT : _TimeZoneProtocol, @unchecked Sendable { let name: String required package init?(identifier: String) { - fatalError("Unexpected init") + guard let offset = TimeZone.tryParseGMTName(identifier), let offsetName = TimeZone.nameForSecondsFromGMT(offset) else { + return nil + } + + self.name = offsetName + self.offset = offset } required package init?(secondsFromGMT: Int) { diff --git a/Sources/FoundationInternationalization/TimeZone/TimeZone_GMTICU.swift b/Sources/FoundationInternationalization/TimeZone/TimeZone_GMTICU.swift index 980e37b55..2de3e091b 100644 --- a/Sources/FoundationInternationalization/TimeZone/TimeZone_GMTICU.swift +++ b/Sources/FoundationInternationalization/TimeZone/TimeZone_GMTICU.swift @@ -26,9 +26,15 @@ private func _timeZoneGMTClass_localized() -> _TimeZoneProtocol.Type { internal final class _TimeZoneGMTICU : _TimeZoneProtocol, @unchecked Sendable { let offset: Int let name: String - + + // Allow using this class to represent time zone whose names take form of "GMT+" such as "GMT+8". init?(identifier: String) { - fatalError("Unexpected init") + guard let offset = TimeZone.tryParseGMTName(identifier), let offsetName = TimeZone.nameForSecondsFromGMT(offset) else { + return nil + } + + self.name = offsetName + self.offset = offset } init?(secondsFromGMT: Int) { @@ -79,29 +85,22 @@ internal final class _TimeZoneGMTICU : _TimeZoneProtocol, @unchecked Sendable { default: false } - // TODO: Consider using ICU C++ API instead of a date formatter here + // TODO: Consider implementing this ourselves let timeZoneIdentifier = Array(name.utf16) let result: String? = timeZoneIdentifier.withUnsafeBufferPointer { var status = U_ZERO_ERROR - guard let df = udat_open(UDAT_NONE, UDAT_NONE, locale?.identifier ?? "", $0.baseAddress, Int32($0.count), nil, 0, &status) else { - return nil + let tz = uatimezone_open($0.baseAddress, Int32($0.count), &status) + defer { + uatimezone_close(tz) } - guard status.isSuccess else { return nil } - defer { udat_close(df) } - - let pattern = "vvvv" - let patternUTF16 = Array(pattern.utf16) - return patternUTF16.withUnsafeBufferPointer { - udat_applyPattern(df, UBool.false, $0.baseAddress, Int32(isShort ? 1 : $0.count)) - - return _withResizingUCharBuffer { buffer, size, status in - udat_format(df, ucal_getNow(), buffer, size, nil, &status) - } + let result: String? = _withResizingUCharBuffer { buffer, size, status in + uatimezone_getDisplayName(tz, isShort ? UTIMEZONE_SHORT: UTIMEZONE_LONG, locale?.identifier ?? "", buffer, size, &status) } + return result } return result From bd80d89e935713a7b41fb3e8a9ebc86fdcfcbcfa Mon Sep 17 00:00:00 2001 From: Tina Liu Date: Tue, 16 Dec 2025 09:25:52 +0800 Subject: [PATCH 3/4] Update benchmark --- .../BenchmarkTimeZone.swift | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/Benchmarks/Benchmarks/Internationalization/BenchmarkTimeZone.swift b/Benchmarks/Benchmarks/Internationalization/BenchmarkTimeZone.swift index c69dd9dc6..729c3150a 100644 --- a/Benchmarks/Benchmarks/Internationalization/BenchmarkTimeZone.swift +++ b/Benchmarks/Benchmarks/Internationalization/BenchmarkTimeZone.swift @@ -28,10 +28,17 @@ let benchmarks = { #endif let testDates = { - var now = Date.now + let seeds: [Date] = [ + Date(timeIntervalSince1970: -2827137600), // 1880-05-30T12:00:00 + Date(timeIntervalSince1970: 0), + Date.now, + Date(timeIntervalSince1970: 26205249600) // 2800-05-30 + ] var dates: [Date] = [] - for i in 0...10000 { - dates.append(Date(timeInterval: Double(i * 3600), since: now)) + for seed in seeds { + for i in 0...2000 { + dates.append(Date(timeInterval: Double(i * 3600), since: seed)) + } } return dates }() @@ -77,24 +84,56 @@ func timeZoneBenchmarks() { } let locale = Locale(identifier: "jp_JP") + let gmtOffsetTimeZoneConfiguration = Benchmark.Configuration(scalingFactor: .mega) + + var gmtOffsetTimeZoneNames = (0...14).map { "GMT+\($0)" } + gmtOffsetTimeZoneNames.append(contentsOf: (0...12).map{ "GMT-\($0)" }) - Benchmark("GMTOffsetTimeZoneAPI", configuration: .init(scalingFactor: .mega)) { benchmark in + Benchmark("GMTOffsetTimeZone-creation", configuration: gmtOffsetTimeZoneConfiguration) { benchmark in + for name in gmtOffsetTimeZoneNames { + guard let gmtPlus = TimeZone(identifier: name) else { + fatalError("unexpected failure when creating time zone: \(name)") + } + blackHole(gmtPlus) + } + } + + Benchmark("GMTOffsetTimeZone-secondsFromGMT", configuration: gmtOffsetTimeZoneConfiguration) { benchmark in for d in testDates { let secondsFromGMT = gmtPlus8.secondsFromGMT(for: d) blackHole(secondsFromGMT) + } + } + Benchmark("GMTOffsetTimeZone-abbreviation", configuration: gmtOffsetTimeZoneConfiguration) { benchmark in + for d in testDates { let abbreviation = gmtPlus8.abbreviation(for: d) blackHole(abbreviation) + } + } - let isDST = gmtPlus8.isDaylightSavingTime(for: d) - blackHole(isDST) - + Benchmark("GMTOffsetTimeZone-nextDaylightSavingTimeTransition", configuration: gmtOffsetTimeZoneConfiguration) { benchmark in + for d in testDates { let nextDST = gmtPlus8.nextDaylightSavingTimeTransition(after: d) blackHole(nextDST) } } - Benchmark("GMTOffsetTimeZone-localizedNames", configuration: .init(scalingFactor: .mega)) { benchmark in + Benchmark("GMTOffsetTimeZone-daylightSavingTimeOffsets", configuration: gmtOffsetTimeZoneConfiguration) { benchmark in + for d in testDates { + let dstOffset = gmtPlus8.daylightSavingTimeOffset(for: d) + blackHole(dstOffset) + } + } + + Benchmark("GMTOffsetTimeZone-isDaylightSavingTime", configuration: gmtOffsetTimeZoneConfiguration) { benchmark in + for d in testDates { + let isDST = gmtPlus8.isDaylightSavingTime(for: d) + blackHole(isDST) + } + } + + Benchmark("GMTOffsetTimeZone-localizedNames", configuration: gmtOffsetTimeZoneConfiguration) { benchmark in for style in [TimeZone.NameStyle.generic, .standard, .shortGeneric, .shortStandard, .daylightSaving, .shortDaylightSaving] { let localizedName = gmtPlus8.localizedName(for: style, locale: locale) blackHole(localizedName) From 1baa2eec84a790dc7431b5ed2e5d940f5f0597be Mon Sep 17 00:00:00 2001 From: Tina Liu Date: Thu, 18 Dec 2025 07:20:56 +0800 Subject: [PATCH 4/4] Add a comment to clarify it's safe to close uatimezone --- .../TimeZone/TimeZone_GMTICU.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/FoundationInternationalization/TimeZone/TimeZone_GMTICU.swift b/Sources/FoundationInternationalization/TimeZone/TimeZone_GMTICU.swift index 2de3e091b..d273ea61e 100644 --- a/Sources/FoundationInternationalization/TimeZone/TimeZone_GMTICU.swift +++ b/Sources/FoundationInternationalization/TimeZone/TimeZone_GMTICU.swift @@ -91,6 +91,7 @@ internal final class _TimeZoneGMTICU : _TimeZoneProtocol, @unchecked Sendable { var status = U_ZERO_ERROR let tz = uatimezone_open($0.baseAddress, Int32($0.count), &status) defer { + // `uatimezone_close` checks for nil input, so it's safe to do it even there's an error. uatimezone_close(tz) } guard status.isSuccess else {