diff --git a/Benchmarks/Benchmarks/Internationalization/BenchmarkTimeZone.swift b/Benchmarks/Benchmarks/Internationalization/BenchmarkTimeZone.swift index 85f372750..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 }() @@ -71,6 +78,68 @@ 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") + let gmtOffsetTimeZoneConfiguration = Benchmark.Configuration(scalingFactor: .mega) + + var gmtOffsetTimeZoneNames = (0...14).map { "GMT+\($0)" } + gmtOffsetTimeZoneNames.append(contentsOf: (0...12).map{ "GMT-\($0)" }) + + 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) + } + } + + Benchmark("GMTOffsetTimeZone-nextDaylightSavingTimeTransition", configuration: gmtOffsetTimeZoneConfiguration) { benchmark in + for d in testDates { + let nextDST = gmtPlus8.nextDaylightSavingTimeTransition(after: d) + blackHole(nextDST) + } + } + + 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) + } + } + } 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..d273ea61e 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,23 @@ 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` checks for nil input, so it's safe to do it even there's an error. + 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 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")!