mirror of
https://github.com/apple/swift-foundation.git
synced 2025-05-28 01:30:46 +08:00
957 lines
40 KiB
Swift
957 lines
40 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
//
|
|
// This source file is part of the Swift.org open source project
|
|
//
|
|
// Copyright (c) 2020 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
|
|
//
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
|
|
extension Date {
|
|
public func ISO8601Format(_ style: ISO8601FormatStyle = .init()) -> String {
|
|
return style.format(self)
|
|
}
|
|
}
|
|
|
|
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
|
|
extension Date {
|
|
|
|
/// Options for generating and parsing string representations of dates following the ISO 8601 standard.
|
|
public struct ISO8601FormatStyle : Sendable {
|
|
public enum TimeZoneSeparator : String, Codable, Sendable {
|
|
case colon = ":"
|
|
case omitted = ""
|
|
}
|
|
|
|
public enum DateSeparator : String, Codable, Sendable {
|
|
case dash = "-"
|
|
case omitted = ""
|
|
}
|
|
|
|
public enum TimeSeparator : String, Codable, Sendable {
|
|
case colon = ":"
|
|
case omitted = ""
|
|
}
|
|
|
|
public enum DateTimeSeparator : String, Codable, Sendable {
|
|
case space = " "
|
|
case standard = "'T'"
|
|
}
|
|
|
|
// `package` visibility so Date+ISO8601FormatStyleParsing.swift can see this
|
|
package struct Fields : Codable, Hashable, OptionSet {
|
|
package var rawValue: UInt
|
|
package init(rawValue: UInt) {
|
|
self.rawValue = rawValue
|
|
}
|
|
|
|
package static var year: Self { Self(rawValue: 1 << 0) }
|
|
package static var month: Self { Self(rawValue: 1 << 1) }
|
|
package static var weekOfYear: Self { Self(rawValue: 1 << 2) }
|
|
package static var day: Self { Self(rawValue: 1 << 3) }
|
|
package static var time: Self { Self(rawValue: 1 << 4) }
|
|
package static var timeZone: Self { Self(rawValue: 1 << 5) }
|
|
|
|
package init(from decoder: any Decoder) throws {
|
|
let c = try decoder.singleValueContainer()
|
|
rawValue = try c.decode(UInt.self)
|
|
}
|
|
|
|
package func encode(to encoder: any Encoder) throws {
|
|
var c = encoder.singleValueContainer()
|
|
try c.encode(rawValue)
|
|
}
|
|
}
|
|
|
|
public private(set) var timeSeparator: TimeSeparator
|
|
public private(set) var includingFractionalSeconds: Bool
|
|
public private(set) var timeZoneSeparator: TimeZoneSeparator
|
|
public private(set) var dateSeparator: DateSeparator
|
|
public private(set) var dateTimeSeparator: DateTimeSeparator
|
|
private var _formatFields: Fields = []
|
|
|
|
/// This is a cache of the Gregorian Calendar, updated if the time zone changes.
|
|
/// In the future we can eliminate this by moving the calculations for the gregorian calendar into static functions there.
|
|
private var _calendar: _CalendarGregorian
|
|
|
|
mutating func insertFormatFields(_ fields: Fields) {
|
|
_formatFields.insert(fields)
|
|
}
|
|
|
|
// `package` visibility so Date+ISO8601FormatStyleParsing.swift can see this
|
|
package var formatFields: Fields {
|
|
if _formatFields.isEmpty {
|
|
return [ .year, .month, .day, .time, .timeZone]
|
|
} else {
|
|
return _formatFields
|
|
}
|
|
}
|
|
|
|
enum CodingKeys : String, CodingKey {
|
|
case timeZoneSeparator
|
|
case timeZone
|
|
case fields
|
|
case dateTimeSeparator
|
|
case includingFractionalSeconds
|
|
case dateSeparator
|
|
case timeSeparator
|
|
}
|
|
|
|
// Encoding
|
|
|
|
public init(from decoder: any Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
timeZoneSeparator = try c.decode(TimeZoneSeparator.self, forKey: .timeZoneSeparator)
|
|
timeZone = try c.decode(TimeZone.self, forKey: .timeZone)
|
|
_formatFields = try c.decode(Fields.self, forKey: .fields)
|
|
dateTimeSeparator = try c.decode(DateTimeSeparator.self, forKey: .dateTimeSeparator)
|
|
includingFractionalSeconds = try c.decode(Bool.self, forKey: .includingFractionalSeconds)
|
|
dateSeparator = try c.decode(DateSeparator.self, forKey: .dateSeparator)
|
|
timeSeparator = try c.decode(TimeSeparator.self, forKey: .timeSeparator)
|
|
|
|
_calendar = _CalendarGregorian(identifier: .gregorian, timeZone: timeZone, locale: Locale.unlocalized, firstWeekday: 2, minimumDaysInFirstWeek: 4, gregorianStartDate: nil)
|
|
}
|
|
|
|
public func encode(to encoder: any Encoder) throws {
|
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
|
try c.encode(timeZoneSeparator, forKey: .timeZoneSeparator)
|
|
try c.encode(timeZone, forKey: .timeZone)
|
|
try c.encode(_formatFields, forKey: .fields)
|
|
try c.encode(dateTimeSeparator, forKey: .dateTimeSeparator)
|
|
try c.encode(includingFractionalSeconds, forKey: .includingFractionalSeconds)
|
|
try c.encode(dateSeparator, forKey: .dateSeparator)
|
|
try c.encode(timeSeparator, forKey: .timeSeparator)
|
|
}
|
|
|
|
public func hash(into hasher: inout Hasher) {
|
|
hasher.combine(timeZoneSeparator)
|
|
hasher.combine(timeZone)
|
|
hasher.combine(_formatFields)
|
|
hasher.combine(dateTimeSeparator)
|
|
hasher.combine(includingFractionalSeconds)
|
|
hasher.combine(dateSeparator)
|
|
hasher.combine(timeSeparator)
|
|
}
|
|
|
|
public static func ==(lhs: ISO8601FormatStyle, rhs: ISO8601FormatStyle) -> Bool {
|
|
lhs.timeZoneSeparator == rhs.timeZoneSeparator &&
|
|
lhs.timeZone == rhs.timeZone &&
|
|
lhs._formatFields == rhs._formatFields &&
|
|
lhs.dateTimeSeparator == rhs.dateTimeSeparator &&
|
|
lhs.includingFractionalSeconds == rhs.includingFractionalSeconds &&
|
|
lhs.dateSeparator == rhs.dateSeparator &&
|
|
lhs.timeSeparator == rhs.timeSeparator
|
|
}
|
|
|
|
/// The time zone to use to create and parse date representations.
|
|
public var timeZone: TimeZone = TimeZone(secondsFromGMT: 0)! {
|
|
didSet {
|
|
// Locale.unlocalized is `en_001`, which is equivalent to `en_US_POSIX` for our needs.
|
|
_calendar = _CalendarGregorian(identifier: .gregorian, timeZone: timeZone, locale: Locale.unlocalized, firstWeekday: 2, minimumDaysInFirstWeek: 4, gregorianStartDate: nil)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@_disfavoredOverload
|
|
public init(dateSeparator: DateSeparator = .dash, dateTimeSeparator: DateTimeSeparator = .standard, timeZone: TimeZone = TimeZone(secondsFromGMT: 0)!) {
|
|
self.dateSeparator = dateSeparator
|
|
self.dateTimeSeparator = dateTimeSeparator
|
|
self.timeZone = timeZone
|
|
self.timeSeparator = .colon
|
|
self.timeZoneSeparator = .omitted
|
|
self.includingFractionalSeconds = false
|
|
|
|
_calendar = _CalendarGregorian(identifier: .gregorian, timeZone: timeZone, locale: Locale.unlocalized, firstWeekday: 2, minimumDaysInFirstWeek: 4, gregorianStartDate: nil)
|
|
}
|
|
|
|
// The default is the format of RFC 3339 with no fractional seconds: "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'"
|
|
public init(dateSeparator: DateSeparator = .dash, dateTimeSeparator: DateTimeSeparator = .standard, timeSeparator: TimeSeparator = .colon, timeZoneSeparator: TimeZoneSeparator = .omitted, includingFractionalSeconds: Bool = false, timeZone: TimeZone = TimeZone(secondsFromGMT: 0)!) {
|
|
self.dateSeparator = dateSeparator
|
|
self.dateTimeSeparator = dateTimeSeparator
|
|
self.timeZone = timeZone
|
|
self.timeSeparator = timeSeparator
|
|
self.timeZoneSeparator = timeZoneSeparator
|
|
self.includingFractionalSeconds = includingFractionalSeconds
|
|
|
|
_calendar = _CalendarGregorian(identifier: .gregorian, timeZone: timeZone, locale: Locale.unlocalized, firstWeekday: 2, minimumDaysInFirstWeek: 4, gregorianStartDate: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
|
|
extension Date.ISO8601FormatStyle {
|
|
public func year() -> Self {
|
|
var new = self
|
|
new.insertFormatFields(.year)
|
|
return new
|
|
}
|
|
|
|
public func weekOfYear() -> Self {
|
|
var new = self
|
|
new.insertFormatFields(.weekOfYear)
|
|
return new
|
|
}
|
|
|
|
public func month() -> Self {
|
|
var new = self
|
|
new.insertFormatFields(.month)
|
|
return new
|
|
}
|
|
|
|
public func day() -> Self {
|
|
var new = self
|
|
new.insertFormatFields(.day)
|
|
return new
|
|
}
|
|
|
|
public func time(includingFractionalSeconds: Bool) -> Self {
|
|
var new = self
|
|
new.insertFormatFields(.time)
|
|
new.includingFractionalSeconds = includingFractionalSeconds
|
|
return new
|
|
}
|
|
|
|
public func timeZone(separator: TimeZoneSeparator) -> Self {
|
|
var new = self
|
|
new.insertFormatFields(.timeZone)
|
|
new.timeZoneSeparator = separator
|
|
return new
|
|
}
|
|
|
|
public func dateSeparator(_ separator: DateSeparator) -> Self {
|
|
var new = self
|
|
new.dateSeparator = separator
|
|
return new
|
|
}
|
|
|
|
public func dateTimeSeparator(_ separator: DateTimeSeparator) -> Self {
|
|
var new = self
|
|
new.dateTimeSeparator = separator
|
|
return new
|
|
}
|
|
|
|
public func timeSeparator(_ separator: TimeSeparator) -> Self {
|
|
var new = self
|
|
new.timeSeparator = separator
|
|
return new
|
|
}
|
|
|
|
public func timeZoneSeparator(_ separator: TimeZoneSeparator) -> Self {
|
|
var new = self
|
|
new.timeZoneSeparator = separator
|
|
return new
|
|
}
|
|
}
|
|
|
|
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
|
|
extension Date.ISO8601FormatStyle : FormatStyle {
|
|
|
|
public func format(_ value: Date) -> String {
|
|
var whichComponents = Calendar.ComponentSet()
|
|
let fields = formatFields
|
|
|
|
// If we use week of year, don't bother with year
|
|
if fields.contains(.year) && !fields.contains(.weekOfYear) {
|
|
whichComponents.insert(.era)
|
|
whichComponents.insert(.year)
|
|
}
|
|
|
|
if fields.contains(.month) {
|
|
whichComponents.insert(.month)
|
|
}
|
|
|
|
if fields.contains(.weekOfYear) {
|
|
whichComponents.insert([.weekOfYear, .yearForWeekOfYear])
|
|
}
|
|
|
|
if fields.contains(.day) {
|
|
if fields.contains(.weekOfYear) {
|
|
whichComponents.insert(.weekday)
|
|
} else if fields.contains(.month) {
|
|
whichComponents.insert(.day)
|
|
} else {
|
|
whichComponents.insert(.dayOfYear)
|
|
}
|
|
}
|
|
|
|
if fields.contains(.time) {
|
|
whichComponents.insert([.hour, .minute, .second])
|
|
if includingFractionalSeconds {
|
|
whichComponents.insert(.nanosecond)
|
|
}
|
|
}
|
|
|
|
let secondsFromGMT: Int?
|
|
let components = _calendar.dateComponents(whichComponents, from: value)
|
|
if fields.contains(.timeZone) {
|
|
secondsFromGMT = timeZone.secondsFromGMT(for: value)
|
|
} else {
|
|
secondsFromGMT = nil
|
|
}
|
|
return format(components, appendingTimeZoneOffset: secondsFromGMT)
|
|
}
|
|
|
|
func format(_ components: DateComponents, appendingTimeZoneOffset timeZoneOffset: Int?) -> String {
|
|
var needSeparator = false
|
|
let capacity = 128 // It is believed no ISO8601 date can exceed this size
|
|
let result = withUnsafeTemporaryAllocation(of: CChar.self, capacity: capacity + 1) { _buffer in
|
|
var buffer = OutputBuffer(initializing: _buffer.baseAddress!, capacity: _buffer.count)
|
|
|
|
let asciiZero = CChar(48)
|
|
|
|
func append(_ i: Int, zeroPad: Int, buffer: inout OutputBuffer<CChar>) {
|
|
if i < 10 {
|
|
if zeroPad - 1 > 0 {
|
|
for _ in 0..<zeroPad-1 { buffer.appendElement(asciiZero) }
|
|
}
|
|
buffer.appendElement(asciiZero + CChar(i))
|
|
} else if i < 100 {
|
|
if zeroPad - 2 > 0 {
|
|
for _ in 0..<zeroPad-2 { buffer.appendElement(asciiZero) }
|
|
}
|
|
let (tens, ones) = i.quotientAndRemainder(dividingBy: 10)
|
|
buffer.appendElement(asciiZero + CChar(tens))
|
|
buffer.appendElement(asciiZero + CChar(ones))
|
|
} else if i < 1000 {
|
|
if zeroPad - 3 > 0 {
|
|
for _ in 0..<zeroPad-3 { buffer.appendElement(asciiZero) }
|
|
}
|
|
let (hundreds, remainder) = i.quotientAndRemainder(dividingBy: 100)
|
|
let (tens, ones) = remainder.quotientAndRemainder(dividingBy: 10)
|
|
buffer.appendElement(asciiZero + CChar(hundreds))
|
|
buffer.appendElement(asciiZero + CChar(tens))
|
|
buffer.appendElement(asciiZero + CChar(ones))
|
|
} else if i < 10000 {
|
|
if zeroPad - 4 > 0 {
|
|
for _ in 0..<zeroPad-4 { buffer.appendElement(asciiZero) }
|
|
}
|
|
let (thousands, remainder) = i.quotientAndRemainder(dividingBy: 1000)
|
|
let (hundreds, remainder2) = remainder.quotientAndRemainder(dividingBy: 100)
|
|
let (tens, ones) = remainder2.quotientAndRemainder(dividingBy: 10)
|
|
buffer.appendElement(asciiZero + CChar(thousands))
|
|
buffer.appendElement(asciiZero + CChar(hundreds))
|
|
buffer.appendElement(asciiZero + CChar(tens))
|
|
buffer.appendElement(asciiZero + CChar(ones))
|
|
} else {
|
|
// Special case - we don't do zero padding
|
|
var desc = i.numericStringRepresentation
|
|
desc.withUTF8 {
|
|
$0.withMemoryRebound(to: CChar.self) { buf in
|
|
buffer.append(fromContentsOf: buf)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let asciiColon = CChar(58)
|
|
let asciiDash = CChar(45)
|
|
let asciiSpace = CChar(32)
|
|
let asciiPeriod = CChar(46)
|
|
let asciiTimeSeparator = CChar(84)
|
|
let asciiWeekOfYearSeparator = CChar(87)
|
|
let asciiZulu = CChar(90)
|
|
let asciiPlus = CChar(43)
|
|
let asciiMinus = CChar(45) // Same as dash, renamed for clarity
|
|
let asciiNull = CChar(0)
|
|
|
|
if formatFields.contains(.year) {
|
|
if formatFields.contains(.weekOfYear), let y = components.yearForWeekOfYear {
|
|
append(y, zeroPad: 4, buffer: &buffer)
|
|
} else {
|
|
var y = components.year!
|
|
if let era = components.era, era == 0 {
|
|
y = 1 - y
|
|
}
|
|
if y < 0 {
|
|
buffer.appendElement(asciiMinus)
|
|
y = -y
|
|
}
|
|
append(y, zeroPad: 4, buffer: &buffer)
|
|
}
|
|
|
|
needSeparator = true
|
|
}
|
|
|
|
if formatFields.contains(.month) {
|
|
if needSeparator && dateSeparator == .dash {
|
|
buffer.appendElement(asciiDash)
|
|
}
|
|
let m = components.month!
|
|
append(m, zeroPad: 2, buffer: &buffer)
|
|
needSeparator = true
|
|
}
|
|
|
|
if formatFields.contains(.weekOfYear) {
|
|
if needSeparator && dateSeparator == .dash {
|
|
buffer.appendElement(asciiDash)
|
|
}
|
|
let woy = components.weekOfYear!
|
|
buffer.appendElement(asciiWeekOfYearSeparator)
|
|
append(woy, zeroPad: 2, buffer: &buffer)
|
|
needSeparator = true
|
|
}
|
|
|
|
if formatFields.contains(.day) {
|
|
if needSeparator && dateSeparator == .dash {
|
|
buffer.appendElement(asciiDash)
|
|
}
|
|
|
|
if formatFields.contains(.weekOfYear) {
|
|
var weekday = components.weekday!
|
|
// Weekday is always less than 10. Our weekdays are offset by 1.
|
|
if weekday >= 10 {
|
|
weekday = 10
|
|
}
|
|
append(weekday - 1, zeroPad: 2, buffer: &buffer)
|
|
} else if formatFields.contains(.month) {
|
|
let day = components.day!
|
|
append(day, zeroPad: 2, buffer: &buffer)
|
|
} else {
|
|
let dayOfYear = components.dayOfYear!
|
|
append(dayOfYear, zeroPad: 3, buffer: &buffer)
|
|
}
|
|
|
|
needSeparator = true
|
|
}
|
|
|
|
if formatFields.contains(.time) {
|
|
if needSeparator {
|
|
switch dateTimeSeparator {
|
|
case .space: buffer.appendElement(asciiSpace)
|
|
case .standard: buffer.appendElement(asciiTimeSeparator)
|
|
}
|
|
}
|
|
|
|
let h = components.hour!
|
|
let m = components.minute!
|
|
let s = components.second!
|
|
|
|
switch timeSeparator {
|
|
case .colon:
|
|
append(h, zeroPad: 2, buffer: &buffer)
|
|
buffer.appendElement(asciiColon)
|
|
append(m, zeroPad: 2, buffer: &buffer)
|
|
buffer.appendElement(asciiColon)
|
|
append(s, zeroPad: 2, buffer: &buffer)
|
|
case .omitted:
|
|
append(h, zeroPad: 2, buffer: &buffer)
|
|
append(m, zeroPad: 2, buffer: &buffer)
|
|
append(s, zeroPad: 2, buffer: &buffer)
|
|
}
|
|
|
|
if includingFractionalSeconds {
|
|
let ns = components.nanosecond!
|
|
let ms = Int((Double(ns) / 1_000_000.0).rounded(.towardZero))
|
|
buffer.appendElement(asciiPeriod)
|
|
append(ms, zeroPad: 3, buffer: &buffer)
|
|
}
|
|
|
|
needSeparator = true
|
|
}
|
|
|
|
if formatFields.contains(.timeZone) {
|
|
// A time zone name, not the same as the abbreviated name from TimeZone. e.g., that one includes a `:`.
|
|
var secondsFromGMT: Int
|
|
if let timeZoneOffset, (-18 * 3600 < timeZoneOffset && timeZoneOffset < 18 * 3600) {
|
|
secondsFromGMT = timeZoneOffset
|
|
} else {
|
|
secondsFromGMT = 0
|
|
}
|
|
|
|
if secondsFromGMT == 0 {
|
|
buffer.appendElement(asciiZulu)
|
|
} else {
|
|
let (hour, minuteAndSecond) = abs(secondsFromGMT).quotientAndRemainder(dividingBy: 3600)
|
|
let (minute, second) = minuteAndSecond.quotientAndRemainder(dividingBy: 60)
|
|
|
|
if secondsFromGMT < 0 {
|
|
buffer.appendElement(asciiMinus)
|
|
} else {
|
|
buffer.appendElement(asciiPlus)
|
|
}
|
|
append(hour, zeroPad: 2, buffer: &buffer)
|
|
if timeZoneSeparator == .colon {
|
|
buffer.appendElement(asciiColon)
|
|
}
|
|
append(minute, zeroPad: 2, buffer: &buffer)
|
|
if second != 0 {
|
|
if timeZoneSeparator == .colon {
|
|
buffer.appendElement(asciiColon)
|
|
}
|
|
append(second, zeroPad: 2, buffer: &buffer)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Null-terminate
|
|
buffer.appendElement(asciiNull)
|
|
|
|
// Make a string
|
|
let initialized = buffer.relinquishBorrowedMemory()
|
|
return String(validatingUTF8: initialized.baseAddress!)!
|
|
}
|
|
|
|
return result
|
|
}
|
|
}
|
|
|
|
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
|
|
extension Date.ISO8601FormatStyle {
|
|
private struct ComponentsParseResult {
|
|
var consumed: Int
|
|
var components: DateComponents
|
|
}
|
|
|
|
private func components(from inputString: String, in view: borrowing BufferView<UInt8>) throws -> ComponentsParseResult {
|
|
let fields = formatFields
|
|
|
|
let asciiDash : UInt8 = 45 // -
|
|
let asciiW : UInt8 = 87 // W
|
|
let asciiT : UInt8 = 84 // T
|
|
let asciiZero : UInt8 = 48 // 0
|
|
let asciiNine : UInt8 = 57 // 9
|
|
let asciiSpace : UInt8 = 32 // space
|
|
let asciiColon : UInt8 = 58 // :
|
|
let asciiPeriod : UInt8 = 46 // .
|
|
let asciiMinus : UInt8 = 45 // same as -
|
|
let asciiPlus : UInt8 = 43 // +
|
|
|
|
func isDigit(_ x: UInt8) -> Bool {
|
|
x >= asciiZero && x <= asciiNine
|
|
}
|
|
|
|
func expectCharacter(_ expected: UInt8, _ i: inout BufferView<UInt8>.Iterator) throws {
|
|
guard let parsed = i.next(), parsed == expected else {
|
|
throw parseError(inputString, exampleFormattedString: self.format(Date.now))
|
|
}
|
|
}
|
|
|
|
func expectOneOrMoreCharacters(_ expected: UInt8, _ i: inout BufferView<UInt8>.Iterator) throws {
|
|
guard let parsed = i.next(), parsed == expected else {
|
|
throw parseError(inputString, exampleFormattedString: self.format(Date.now))
|
|
}
|
|
|
|
while let parsed = i.peek(), parsed == expected {
|
|
i.advance()
|
|
}
|
|
}
|
|
|
|
func expectZeroOrMoreCharacters(_ expected: UInt8, _ i: inout BufferView<UInt8>.Iterator) {
|
|
while let parsed = i.peek(), parsed == expected {
|
|
i.advance()
|
|
}
|
|
}
|
|
|
|
func digits(maxDigits: Int? = nil, nanoseconds: Bool = false, _ i: inout BufferView<UInt8>.Iterator) throws -> Int {
|
|
// Consume all leading zeros, parse until we no longer see a digit
|
|
var result = 0
|
|
var count = 0
|
|
// Cap at 10 digits max to avoid overflow
|
|
let max = min(maxDigits ?? 10, 10)
|
|
while let next = i.peek(), isDigit(next) {
|
|
let digit = Int(next - asciiZero)
|
|
result *= 10
|
|
result += digit
|
|
i.advance()
|
|
count += 1
|
|
if count >= max { break }
|
|
}
|
|
|
|
guard count > 0 else {
|
|
// No digits actually found
|
|
throw parseError(inputString, exampleFormattedString: self.format(Date.now))
|
|
}
|
|
|
|
if nanoseconds {
|
|
// Keeps us in the land of integers
|
|
if count == 1 { return result * 100_000_000 }
|
|
if count == 2 { return result * 10_000_000 }
|
|
if count == 3 { return result * 1_000_000 }
|
|
if count == 4 { return result * 100_000 }
|
|
if count == 5 { return result * 10_000 }
|
|
if count == 6 { return result * 1_000 }
|
|
if count == 7 { return result * 100 }
|
|
if count == 8 { return result * 10 }
|
|
if count == 9 { return result }
|
|
throw parseError(inputString, exampleFormattedString: self.format(Date.now))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
var it = view.makeIterator()
|
|
var needsSeparator = false
|
|
var dc = DateComponents()
|
|
if fields.contains(.year) {
|
|
let max = dateSeparator == .omitted ? 4 : nil
|
|
let value = try digits(maxDigits: max, &it)
|
|
if fields.contains(.weekOfYear) {
|
|
dc.yearForWeekOfYear = value
|
|
} else {
|
|
dc.year = value
|
|
}
|
|
|
|
needsSeparator = true
|
|
} else {
|
|
// Support for deprecated formats with missing values
|
|
dc.year = 1970
|
|
}
|
|
|
|
if fields.contains(.month) {
|
|
if needsSeparator && dateSeparator == .dash {
|
|
try expectCharacter(asciiDash, &it)
|
|
}
|
|
|
|
// parse month digits
|
|
let max = dateSeparator == .omitted ? 2 : nil
|
|
let value = try digits(maxDigits: max, &it)
|
|
guard _calendar.maximumRange(of: .month)!.contains(value) else {
|
|
throw parseError(inputString, exampleFormattedString: self.format(Date.now))
|
|
}
|
|
dc.month = value
|
|
|
|
needsSeparator = true
|
|
} else if fields.contains(.weekOfYear) {
|
|
if needsSeparator && dateSeparator == .dash {
|
|
try expectCharacter(asciiDash, &it)
|
|
}
|
|
// parse W
|
|
try expectCharacter(asciiW, &it)
|
|
|
|
// parse week of year digits
|
|
let max = dateSeparator == .omitted ? 2 : nil
|
|
let value = try digits(maxDigits: max, &it)
|
|
guard _calendar.maximumRange(of: .weekOfYear)!.contains(value) else {
|
|
throw parseError(inputString, exampleFormattedString: self.format(Date.now))
|
|
}
|
|
dc.weekOfYear = value
|
|
|
|
needsSeparator = true
|
|
} else {
|
|
// Support for deprecated formats with missing values
|
|
dc.month = 1
|
|
}
|
|
|
|
if fields.contains(.day) {
|
|
if needsSeparator && dateSeparator == .dash {
|
|
try expectCharacter(asciiDash, &it)
|
|
}
|
|
|
|
if fields.contains(.weekOfYear) {
|
|
// parse day of week ('ee')
|
|
// ISO8601 "1" is Monday. For our date components, 2 is Monday. Add 1 to account for difference.
|
|
let max = dateSeparator == .omitted ? 2 : nil
|
|
let value = (try digits(maxDigits: max, &it) % 7) + 1
|
|
|
|
guard _calendar.maximumRange(of: .weekday)!.contains(value) else {
|
|
throw parseError(inputString, exampleFormattedString: self.format(Date.now))
|
|
}
|
|
dc.weekday = value
|
|
|
|
} else if fields.contains(.month) {
|
|
// parse day of month ('dd')
|
|
let max = dateSeparator == .omitted ? 2 : nil
|
|
let value = try digits(maxDigits: max, &it)
|
|
guard _calendar.maximumRange(of: .day)!.contains(value) else {
|
|
throw parseError(inputString, exampleFormattedString: self.format(Date.now))
|
|
}
|
|
|
|
dc.day = value
|
|
|
|
} else {
|
|
// parse 3 digit day of year ('DDD')
|
|
let max = dateSeparator == .omitted ? 3 : nil
|
|
let value = try digits(maxDigits: max, &it)
|
|
guard _calendar.maximumRange(of: .dayOfYear)!.contains(value) else {
|
|
throw parseError(inputString, exampleFormattedString: self.format(Date.now))
|
|
}
|
|
|
|
dc.dayOfYear = value
|
|
}
|
|
|
|
needsSeparator = true
|
|
}
|
|
|
|
if fields.contains(.time) {
|
|
if needsSeparator {
|
|
switch dateTimeSeparator {
|
|
case .standard:
|
|
// parse T
|
|
try expectCharacter(asciiT, &it)
|
|
case .space:
|
|
// parse any number of spaces
|
|
try expectOneOrMoreCharacters(asciiSpace, &it)
|
|
}
|
|
}
|
|
|
|
switch timeSeparator {
|
|
case .colon:
|
|
dc.hour = try digits(&it)
|
|
try expectCharacter(asciiColon, &it)
|
|
dc.minute = try digits(&it)
|
|
try expectCharacter(asciiColon, &it)
|
|
dc.second = try digits(&it)
|
|
case .omitted:
|
|
dc.hour = try digits(maxDigits: 2, &it)
|
|
dc.minute = try digits(maxDigits: 2, &it)
|
|
dc.second = try digits(maxDigits: 2, &it)
|
|
}
|
|
|
|
if includingFractionalSeconds {
|
|
try expectCharacter(asciiPeriod, &it)
|
|
|
|
let fractionalSeconds = try digits(nanoseconds: true, &it)
|
|
dc.nanosecond = fractionalSeconds
|
|
}
|
|
|
|
needsSeparator = true
|
|
}
|
|
|
|
if fields.contains(.timeZone) {
|
|
// For compatibility with ICU implementation, if the dateTimeSeparator is a space, consume any number (including zero) of spaces here.
|
|
if dateTimeSeparator == .space {
|
|
expectZeroOrMoreCharacters(asciiSpace, &it)
|
|
}
|
|
|
|
guard let plusOrMinusOrZ = it.next() else {
|
|
// Expected time zone
|
|
throw parseError(inputString, exampleFormattedString: self.format(Date.now))
|
|
}
|
|
|
|
let tz: TimeZone
|
|
|
|
if plusOrMinusOrZ == UInt8(ascii: "Z") || plusOrMinusOrZ == UInt8(ascii: "z") {
|
|
tz = .gmt
|
|
} else {
|
|
var tzOffset = 0
|
|
let positive: Bool
|
|
var skipDigits = false
|
|
|
|
// Allow GMT, or UTC
|
|
if (plusOrMinusOrZ == UInt8(ascii: "G") || plusOrMinusOrZ == UInt8(ascii: "g")),
|
|
let m = it.next(), (m == UInt8(ascii: "M") || m == UInt8(ascii: "m")),
|
|
let t = it.next(), (t == UInt8(ascii: "T") || t == UInt8(ascii: "t")) {
|
|
// Allow GMT followed by + or -, or end of string, or other
|
|
if let next = it.peek(), (next == asciiPlus || next == asciiMinus) {
|
|
if next == asciiPlus { positive = true }
|
|
else { positive = false }
|
|
it.advance()
|
|
} else {
|
|
positive = true
|
|
tzOffset = 0
|
|
skipDigits = true
|
|
}
|
|
} else if (plusOrMinusOrZ == UInt8(ascii: "U") || plusOrMinusOrZ == UInt8(ascii: "u")),
|
|
let t = it.next(), (t == UInt8(ascii: "T") || t == UInt8(ascii: "t")),
|
|
let c = it.next(), (c == UInt8(ascii: "C") || c == UInt8(ascii: "c")) {
|
|
// Allow UTC followed by + or -, or end of string, or other
|
|
if let next = it.peek(), (next == asciiPlus || next == asciiMinus) {
|
|
if next == asciiPlus { positive = true }
|
|
else { positive = false }
|
|
it.advance()
|
|
} else {
|
|
positive = true
|
|
tzOffset = 0
|
|
skipDigits = true
|
|
}
|
|
} else if plusOrMinusOrZ == asciiPlus {
|
|
positive = true
|
|
} else if plusOrMinusOrZ == asciiMinus {
|
|
positive = false
|
|
} else {
|
|
// Expected time zone, found garbage
|
|
throw parseError(inputString, exampleFormattedString: self.format(Date.now))
|
|
}
|
|
|
|
if !skipDigits {
|
|
// Theoretically we would disallow or require the presence of a `:` here. However, the original implementation of this style with ICU accidentally allowed either the presence or absence of the `:` to be parsed regardless of the setting. We preserve that behavior now.
|
|
|
|
// parse Time Zone: ISO8601 extended hms?, with Z
|
|
// examples: -08:00, -07:52:58, Z
|
|
let hours = try digits(maxDigits: 2, &it)
|
|
|
|
// Expect a colon, or not
|
|
if let maybeColon = it.peek(), maybeColon == asciiColon {
|
|
// Throw it away
|
|
it.advance()
|
|
}
|
|
|
|
let minutes = try digits(maxDigits: 2, &it)
|
|
|
|
if let maybeColon = it.peek(), maybeColon == asciiColon {
|
|
// Throw it away
|
|
it.advance()
|
|
}
|
|
|
|
if let secondsTens = it.peek(), isDigit(secondsTens) {
|
|
// We have seconds
|
|
let seconds = try digits(maxDigits: 2, &it)
|
|
tzOffset = (hours * 3600) + (minutes * 60) + seconds
|
|
} else {
|
|
// If the next character is missing, that's allowed - the time can be something like just -0852 and then the string can end
|
|
tzOffset = (hours * 3600) + (minutes * 60)
|
|
}
|
|
}
|
|
|
|
if tzOffset == 0 {
|
|
tz = .gmt
|
|
} else {
|
|
guard let parsedTimeZone = TimeZone(secondsFromGMT: positive ? tzOffset : -tzOffset) else {
|
|
// Out of range time zone
|
|
throw parseError(inputString, exampleFormattedString: self.format(Date.now))
|
|
}
|
|
|
|
tz = parsedTimeZone
|
|
}
|
|
}
|
|
|
|
dc.timeZone = tz
|
|
}
|
|
|
|
// Would be nice to see this functionality on BufferView, but for now we calculate it ourselves.
|
|
let utf8CharactersRead = it.curPointer - view.startIndex._rawValue
|
|
return ComponentsParseResult(consumed: utf8CharactersRead, components: dc)
|
|
}
|
|
}
|
|
|
|
// MARK: `FormatStyle` protocol membership
|
|
|
|
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
|
|
public extension FormatStyle where Self == Date.ISO8601FormatStyle {
|
|
static var iso8601: Self {
|
|
return Date.ISO8601FormatStyle()
|
|
}
|
|
}
|
|
|
|
// MARK: - Parsing
|
|
|
|
// MARK: `FormatStyle` protocol membership
|
|
|
|
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
|
|
public extension ParseableFormatStyle where Self == Date.ISO8601FormatStyle {
|
|
static var iso8601: Self { .init() }
|
|
}
|
|
|
|
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
|
|
public extension ParseStrategy where Self == Date.ISO8601FormatStyle {
|
|
@_disfavoredOverload
|
|
static var iso8601: Self { .init() }
|
|
}
|
|
|
|
|
|
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
|
|
extension Date.ISO8601FormatStyle : ParseStrategy {
|
|
public func parse(_ value: String) throws -> Date {
|
|
guard let (_, date) = parse(value, in: value.startIndex..<value.endIndex) else {
|
|
throw parseError(value, exampleFormattedString: self.format(Date.now))
|
|
}
|
|
return date
|
|
}
|
|
|
|
package func parse(_ value: String, in range: Range<String.Index>) -> (String.Index, Date)? {
|
|
var v = value[range]
|
|
guard !v.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
let result = v.withUTF8 { buffer -> (Int, Date)? in
|
|
let view = BufferView(unsafeBufferPointer: buffer)!
|
|
|
|
guard let comps = try? components(from: value, in: view) else {
|
|
return nil
|
|
}
|
|
|
|
if let tz = comps.components.timeZone {
|
|
guard let date = _calendar.date(from: comps.components, inTimeZone: tz) else {
|
|
return nil
|
|
}
|
|
|
|
return (comps.consumed, date)
|
|
} else {
|
|
// Use the default time zone of the calendar. Neither date(from:inTimeZone:) nor date(from:) honor the time zone value set in the DateComponents instance.
|
|
// rdar://122918762 (CalendarGregorian's date(from: components) does not honor the DateComponents time zone)
|
|
guard let date = _calendar.date(from: comps.components) else {
|
|
return nil
|
|
}
|
|
|
|
return (comps.consumed, date)
|
|
}
|
|
}
|
|
|
|
guard let result else {
|
|
return nil
|
|
}
|
|
|
|
let endIndex = value.utf8.index(v.startIndex, offsetBy: result.0)
|
|
return (endIndex, result.1)
|
|
}
|
|
}
|
|
|
|
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
|
|
extension Date.ISO8601FormatStyle: ParseableFormatStyle {
|
|
public var parseStrategy: Self {
|
|
return self
|
|
}
|
|
}
|
|
|
|
// MARK: - Regex
|
|
|
|
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
|
|
extension Date.ISO8601FormatStyle : CustomConsumingRegexComponent {
|
|
public typealias RegexOutput = Date
|
|
public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range<String.Index>) throws -> (upperBound: String.Index, output: Date)? {
|
|
guard index < bounds.upperBound else {
|
|
return nil
|
|
}
|
|
// It's important to return nil from parse in case of a failure, not throw. That allows things like the firstMatch regex to work.
|
|
return self.parse(input, in: index..<bounds.upperBound)
|
|
}
|
|
}
|
|
|
|
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
|
|
extension RegexComponent where Self == Date.ISO8601FormatStyle {
|
|
/// Creates a regex component to match an ISO 8601 date and time, such as "2015-11-14'T'15:05:03'Z'", and capture the string as a `Date` using the time zone as specified in the string.
|
|
@_disfavoredOverload
|
|
public static var iso8601: Date.ISO8601FormatStyle {
|
|
return Date.ISO8601FormatStyle()
|
|
}
|
|
|
|
/// Creates a regex component to match an ISO 8601 date and time string, including time zone, and capture the string as a `Date` using the time zone as specified in the string.
|
|
/// - Parameters:
|
|
/// - includingFractionalSeconds: Specifies if the string contains fractional seconds.
|
|
/// - dateSeparator: The separator between date components.
|
|
/// - dateTimeSeparator: The separator between date and time parts.
|
|
/// - timeSeparator: The separator between time components.
|
|
/// - timeZoneSeparator: The separator between time parts in the time zone.
|
|
/// - Returns: A `RegexComponent` to match an ISO 8601 string, including time zone.
|
|
public static func iso8601WithTimeZone(includingFractionalSeconds: Bool = false, dateSeparator: Self.DateSeparator = .dash, dateTimeSeparator: Self.DateTimeSeparator = .standard, timeSeparator: Self.TimeSeparator = .colon, timeZoneSeparator: Self.TimeZoneSeparator = .omitted) -> Self {
|
|
return Date.ISO8601FormatStyle(dateSeparator: dateSeparator, dateTimeSeparator: dateTimeSeparator, timeSeparator: timeSeparator, timeZoneSeparator: timeZoneSeparator, includingFractionalSeconds: includingFractionalSeconds)
|
|
}
|
|
|
|
/// Creates a regex component to match an ISO 8601 date and time string without time zone, and capture the string as a `Date` using the specified `timeZone`. If the string contains time zone designators, matches up until the start of time zone designators.
|
|
/// - Parameters:
|
|
/// - timeZone: The time zone to create the captured `Date` with.
|
|
/// - includingFractionalSeconds: Specifies if the string contains fractional seconds.
|
|
/// - dateSeparator: The separator between date components.
|
|
/// - dateTimeSeparator: The separator between date and time parts.
|
|
/// - timeSeparator: The separator between time components.
|
|
/// - Returns: A `RegexComponent` to match an ISO 8601 string.
|
|
public static func iso8601(timeZone: TimeZone, includingFractionalSeconds: Bool = false, dateSeparator: Self.DateSeparator = .dash, dateTimeSeparator: Self.DateTimeSeparator = .standard, timeSeparator: Self.TimeSeparator = .colon) -> Self {
|
|
return Date.ISO8601FormatStyle(timeZone: timeZone).year().month().day().time(includingFractionalSeconds: includingFractionalSeconds).timeSeparator(timeSeparator).dateSeparator(dateSeparator).dateTimeSeparator(dateTimeSeparator)
|
|
}
|
|
|
|
/// Creates a regex component to match an ISO 8601 date string, such as "2015-11-14", and capture the string as a `Date`. The captured `Date` would be at midnight in the specified `timeZone`.
|
|
/// - Parameters:
|
|
/// - timeZone: The time zone to create the captured `Date` with.
|
|
/// - dateSeparator: The separator between date components.
|
|
/// - Returns: A `RegexComponent` to match an ISO 8601 date string, including time zone.
|
|
public static func iso8601Date(timeZone: TimeZone, dateSeparator: Self.DateSeparator = .dash) -> Self {
|
|
return Date.ISO8601FormatStyle(dateSeparator: dateSeparator, timeZone: timeZone).year().month().day()
|
|
}
|
|
}
|