Tina Liu 47c62e35c8
[Gregorian Calendar] Handle adding date components across DST transition like Calendar_ICU (#352)
Currently we implement adding units larger than `.day` as follows:
- Convert the date to date components
- Update the value of the added component
- Convert the date components back to date

The time of the day information is reduced to a `DateComponents` with, e.g. `hour == 1`, `minute == 30`, at step 1. Then when we convert the `DateComponents` back to `Date` at step 3, we always use the *first* occurrence of the time if it falls into the DST repeated time frame.

This is different from `Calendar_ICU`'s implementation, which uses the *latter* and rewind back the date by the DST transition interval (e.g. 1 hour for Pacific Time).

These yeild the same result except for when the input time and the output time are *both* during DST transition time frame. Update the implementation to match Calendar_ICU's behavior.
2023-12-22 10:11:34 +08:00

273 lines
8.1 KiB
Swift

//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//
package final class _TimeZoneGMT : _TimeZoneProtocol, @unchecked Sendable {
let offset: Int
let name: String
required package init?(identifier: String) {
fatalError("Unexpected init")
}
required package init?(secondsFromGMT: Int) {
guard let name = TimeZone.nameForSecondsFromGMT(secondsFromGMT) else {
return nil
}
self.name = name
offset = secondsFromGMT
}
package var identifier: String {
self.name
}
package func secondsFromGMT(for date: Date) -> Int {
offset
}
package func abbreviation(for date: Date) -> String? {
_TimeZoneGMT.abbreviation(for: offset)
}
package func isDaylightSavingTime(for date: Date) -> Bool {
false
}
package func daylightSavingTimeOffset(for date: Date) -> TimeInterval {
0.0
}
package func rawAndDaylightSavingTimeOffset(for date: Date, repeatedTimePolicy: TimeZone.DaylightSavingTimePolicy, skippedTimePolicy: TimeZone.DaylightSavingTimePolicy) -> (rawOffset: Int, daylightSavingOffset: TimeInterval) {
(offset, 0)
}
package func nextDaylightSavingTimeTransition(after date: Date) -> Date? {
nil
}
package func localizedName(for style: TimeZone.NameStyle, locale: Locale?) -> String? {
// _TimeZoneGMTICU has localization support, if required.
nil
}
package var debugDescription: String {
"gmt offset \(offset)"
}
}
extension _TimeZoneGMT {
package static func abbreviation(for offset: Int) -> String? {
guard !(offset < -18 * 3600 || 18 * 3600 < offset) else {
return nil
}
// Move up by half a minute so that rounding down via division gets us the right answer
var remainder = abs(offset) + 30
let hours = remainder / 3600
remainder = remainder % 3600
let minutes = remainder / 60
if hours == 0 && minutes == 0 {
return "GMT"
}
// This format discards "seconds" values
var result = "GMT"
if offset < 0 {
result += "-"
} else {
result += "+"
}
if hours >= 10 {
// Tens
result.unicodeScalars.append(Unicode.Scalar((hours / 10) + 48)!)
}
// Ones
result.unicodeScalars.append(Unicode.Scalar((hours % 10) + 48)!)
guard minutes > 0 else {
return result
}
// ':'
result.unicodeScalars.append(Unicode.Scalar(58)!)
if minutes >= 10 {
// Tens
result.unicodeScalars.append(Unicode.Scalar((minutes / 10) + 48)!)
} else if minutes > 0 {
// 0 for Tens
result.unicodeScalars.append(Unicode.Scalar(48)!)
}
// Ones
result.unicodeScalars.append(Unicode.Scalar((minutes % 10) + 48)!)
return result
}
}
extension TimeZone {
/// A time zone name, not the same as the abbreviated name above. e.g., that one includes a `:`.
package static func nameForSecondsFromGMT(_ seconds: Int) -> String? {
guard !(seconds < -18 * 3600 || 18 * 3600 < seconds) else {
return nil
}
// Move up by half a minute so that rounding down via division gets us the right answer
let at = abs(seconds) + 30
let hour = at / 3600
let second = at % 3600
let minute = second / 60
if hour == 0 && minute == 0 {
return "GMT"
} else {
let formattedHour = hour < 10 ? "0\(hour)" : "\(hour)"
let formattedMinute = minute < 10 ? "0\(minute)" : "\(minute)"
let negative = seconds < 0
return "GMT\(negative ? "-" : "+")\(formattedHour)\(formattedMinute)"
}
}
// Returns seconds offset (positive or negative or zero) from GMT on success, nil on failure
package static func tryParseGMTName(_ name: String) -> Int? {
// GMT, GMT{+|-}H, GMT{+|-}HH, GMT{+|-}HHMM, GMT{+|-}{H|HH}{:|.}MM
// UTC, UTC{+|-}H, UTC{+|-}HH, UTC{+|-}HHMM, UTC{+|-}{H|HH}{:|.}MM
// where "00" <= HH <= "18", "00" <= MM <= "59", and if HH==18, then MM must == 00
let len = name.count
guard len >= 3 && len <= 9 else {
return nil
}
let isGMT = name.starts(with: "GMT")
let isUTC = name.starts(with: "UTC")
guard isGMT || isUTC else {
return nil
}
if len == 3 {
// GMT or UTC, exactly
return 0
}
guard len >= 5 else {
return nil
}
var idx = name.index(name.startIndex, offsetBy: 3)
let plusOrMinus = name[idx]
let positive = plusOrMinus == "+"
let negative = plusOrMinus == "-"
guard positive || negative else {
return nil
}
let zero: UInt8 = 0x30
let five: UInt8 = 0x35
let nine: UInt8 = 0x39
idx = name.index(after: idx)
let oneHourDigit = name[idx].asciiValue ?? 0
guard oneHourDigit >= zero && oneHourDigit <= nine else {
return nil
}
let hourOne = Int(oneHourDigit - zero)
if len == 5 {
// GMT{+|-}H
if negative {
return -hourOne * 3600
} else {
return hourOne * 3600
}
}
idx = name.index(after: idx)
let twoHourDigitOrPunct = name[idx].asciiValue ?? 0
let colon: UInt8 = 0x3a
let period: UInt8 = 0x2e
let secondHourIsTwoHourDigit = (twoHourDigitOrPunct >= zero && twoHourDigitOrPunct <= nine)
let secondHourIsPunct = twoHourDigitOrPunct == colon || twoHourDigitOrPunct == period
guard secondHourIsTwoHourDigit || secondHourIsPunct else {
return nil
}
let hours: Int
if secondHourIsTwoHourDigit {
hours = 10 * hourOne + Int(twoHourDigitOrPunct - zero)
} else { // secondHourIsPunct
// The above advance of idx 'consumed' the punctuation
hours = hourOne
}
if 18 < hours {
return nil
}
if secondHourIsTwoHourDigit && len == 6 {
// GMT{+|-}HH
if negative {
return -hours * 3600
} else {
return hours * 3600
}
}
if len < 8 {
return nil
}
idx = name.index(after: idx)
let firstMinuteDigitOrPunct = name[idx].asciiValue ?? 0
let firstMinuteIsDigit = (firstMinuteDigitOrPunct >= zero && firstMinuteDigitOrPunct <= five)
let firstMinuteIsPunct = firstMinuteDigitOrPunct == colon || firstMinuteDigitOrPunct == period
guard (firstMinuteIsDigit && len == 8) || (firstMinuteIsPunct && len == 9) else {
return nil
}
if firstMinuteIsPunct {
// Skip the punctuation
idx = name.index(after: idx)
}
let firstMinute = name[idx].asciiValue ?? 0
// Next character must also be a digit, no single-minutes allowed
idx = name.index(after: idx)
let secondMinute = name[idx].asciiValue ?? 0
guard secondMinute >= zero && secondMinute <= nine else {
return nil
}
let minutes = Int(10 * (firstMinute - zero) + (secondMinute - zero))
if hours == 18 && minutes != 0 {
// 18 hours requires 0 minutes
return nil
}
if negative {
return -(hours * 3600 + minutes * 60)
} else {
return hours * 3600 + minutes * 60
}
}
}