Completes issue #11. Tagged.Scalar.timestampValue parses a !!timestamp scalar under the YAML 1.1 grammar into the pure-Swift Tagged.Timestamp (calendar components, timezone offset, secondsSinceEpoch via the days-from-civil closed form; epoch test expectations measured against Python's independent implementation). Tagged.Scalar.binaryValue base64 decodes a !!binary scalar into bytes, accepting unpadded and block scalar wrapped content and rejecting corrupt input whole. Yams hands back Foundation Date/Data; this package is mechanically Foundation-free, so the bridges are caller-owned one-liners: Date(timeIntervalSince1970:) and Data(_:). !!set and !!omap stay skipped since Model.Mapping already preserves order and duplicates. Closes #11
242 lines
7.6 KiB
Swift
242 lines
7.6 KiB
Swift
public extension PureYAML.Tagged {
|
|
/// A parsed YAML 1.1 `!!timestamp` value, held as calendar components
|
|
/// plus a timezone offset, with `secondsSinceEpoch` for the callers that
|
|
/// want an absolute instant (`Date(timeIntervalSince1970:)` bridges it
|
|
/// in one line; the package itself stays Foundation-free).
|
|
///
|
|
/// Accepted source forms are the YAML 1.1 timestamp grammar: the date
|
|
/// alone (`2002-12-14`), or date and time separated by `T`/`t` or
|
|
/// whitespace, with an optional fractional second and an optional
|
|
/// timezone (`Z`, `+5`, `-05:30`). A missing timezone means UTC per the
|
|
/// YAML 1.1 timestamp specification's treatment of unlabeled times.
|
|
struct Timestamp: Equatable, Hashable, Sendable {
|
|
public var year: Int
|
|
public var month: Int
|
|
public var day: Int
|
|
public var hour: Int
|
|
public var minute: Int
|
|
public var second: Int
|
|
/// Fraction of a second in the half-open range `0..<1`.
|
|
public var fraction: Double
|
|
/// Offset from UTC in seconds; `0` for `Z` or an absent timezone.
|
|
public var offsetSeconds: Int
|
|
|
|
public init(
|
|
year: Int,
|
|
month: Int,
|
|
day: Int,
|
|
hour: Int = 0,
|
|
minute: Int = 0,
|
|
second: Int = 0,
|
|
fraction: Double = 0,
|
|
offsetSeconds: Int = 0,
|
|
) {
|
|
self.year = year
|
|
self.month = month
|
|
self.day = day
|
|
self.hour = hour
|
|
self.minute = minute
|
|
self.second = second
|
|
self.fraction = fraction
|
|
self.offsetSeconds = offsetSeconds
|
|
}
|
|
|
|
/// Seconds since 1970-01-01T00:00:00Z, using the proleptic Gregorian
|
|
/// calendar (the days-from-civil closed form), with the timezone
|
|
/// offset applied.
|
|
public var secondsSinceEpoch: Double {
|
|
let shiftedYear = month <= 2 ? year - 1 : year
|
|
let era = (shiftedYear >= 0 ? shiftedYear : shiftedYear - 399) / 400
|
|
let yearOfEra = shiftedYear - era * 400
|
|
let dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1
|
|
let dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear
|
|
let days = era * 146_097 + dayOfEra - 719_468
|
|
let seconds = days * 86400 + hour * 3600 + minute * 60 + second - offsetSeconds
|
|
return Double(seconds) + fraction
|
|
}
|
|
}
|
|
}
|
|
|
|
extension PureYAML.Tagged.Timestamp {
|
|
/// Parses the YAML 1.1 timestamp grammar. Returns `nil` for anything
|
|
/// outside it, including calendar-invalid dates like February 30.
|
|
init?(yaml raw: String) {
|
|
var cursor = Substring(raw)
|
|
guard let date = Self.takeDate(&cursor) else {
|
|
return nil
|
|
}
|
|
if cursor.isEmpty {
|
|
self.init(year: date.year, month: date.month, day: date.day)
|
|
return
|
|
}
|
|
guard
|
|
cursor.takeTimeSeparator(),
|
|
let time = Self.takeTime(&cursor),
|
|
let offsetSeconds = Self.takeOffset(&cursor),
|
|
cursor.isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
self.init(
|
|
year: date.year,
|
|
month: date.month,
|
|
day: date.day,
|
|
hour: time.hour,
|
|
minute: time.minute,
|
|
second: time.second,
|
|
fraction: time.fraction,
|
|
offsetSeconds: offsetSeconds,
|
|
)
|
|
}
|
|
|
|
struct DateParts {
|
|
var year: Int
|
|
var month: Int
|
|
var day: Int
|
|
}
|
|
|
|
struct TimeParts {
|
|
var hour: Int
|
|
var minute: Int
|
|
var second: Int
|
|
var fraction: Double
|
|
}
|
|
|
|
static func takeDate(_ cursor: inout Substring) -> DateParts? {
|
|
guard
|
|
let year = cursor.takeDigits(exactly: 4),
|
|
cursor.take("-"),
|
|
let month = cursor.takeDigits(upTo: 2),
|
|
cursor.take("-"),
|
|
let day = cursor.takeDigits(upTo: 2),
|
|
isValidDate(year: year, month: month, day: day)
|
|
else {
|
|
return nil
|
|
}
|
|
return DateParts(year: year, month: month, day: day)
|
|
}
|
|
|
|
static func takeTime(_ cursor: inout Substring) -> TimeParts? {
|
|
guard
|
|
let hour = cursor.takeDigits(upTo: 2), hour <= 23,
|
|
cursor.take(":"),
|
|
let minute = cursor.takeDigits(exactly: 2), minute <= 59,
|
|
cursor.take(":"),
|
|
let second = cursor.takeDigits(exactly: 2), second <= 59
|
|
else {
|
|
return nil
|
|
}
|
|
var fraction = 0.0
|
|
if cursor.take(".") {
|
|
var scale = 0.1
|
|
while let digit = cursor.takeDigit() {
|
|
fraction += Double(digit) * scale
|
|
scale /= 10
|
|
}
|
|
}
|
|
return TimeParts(hour: hour, minute: minute, second: second, fraction: fraction)
|
|
}
|
|
|
|
/// The timezone part: `Z`, a signed hour with optional `:minutes`, or
|
|
/// nothing (UTC). Returns the offset in seconds, or `nil` on a
|
|
/// malformed offset (which fails the whole parse; absence is `0`).
|
|
static func takeOffset(_ cursor: inout Substring) -> Int? {
|
|
cursor.takeWhitespace()
|
|
if cursor.take("Z") {
|
|
return 0
|
|
}
|
|
guard let sign = cursor.takeSign() else {
|
|
return 0
|
|
}
|
|
guard let offsetHour = cursor.takeDigits(upTo: 2), offsetHour <= 23 else {
|
|
return nil
|
|
}
|
|
var offsetMinute = 0
|
|
if cursor.take(":") {
|
|
guard let parsed = cursor.takeDigits(exactly: 2), parsed <= 59 else {
|
|
return nil
|
|
}
|
|
offsetMinute = parsed
|
|
}
|
|
return sign * (offsetHour * 3600 + offsetMinute * 60)
|
|
}
|
|
|
|
static func isValidDate(year: Int, month: Int, day: Int) -> Bool {
|
|
guard (1 ... 12).contains(month), day >= 1 else {
|
|
return false
|
|
}
|
|
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
|
let lengths = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
return day <= lengths[month - 1]
|
|
}
|
|
}
|
|
|
|
private extension Substring {
|
|
mutating func take(_ character: Character) -> Bool {
|
|
guard first == character else {
|
|
return false
|
|
}
|
|
removeFirst()
|
|
return true
|
|
}
|
|
|
|
mutating func takeDigit() -> Int? {
|
|
guard let first, let digit = first.wholeNumberValue, first.isASCII, (0 ... 9).contains(digit) else {
|
|
return nil
|
|
}
|
|
removeFirst()
|
|
return digit
|
|
}
|
|
|
|
/// Between one and `upTo` digits.
|
|
mutating func takeDigits(upTo limit: Int) -> Int? {
|
|
var value: Int?
|
|
for _ in 0 ..< limit {
|
|
guard let digit = takeDigit() else {
|
|
break
|
|
}
|
|
value = (value ?? 0) * 10 + digit
|
|
}
|
|
return value
|
|
}
|
|
|
|
mutating func takeDigits(exactly count: Int) -> Int? {
|
|
var value = 0
|
|
for _ in 0 ..< count {
|
|
guard let digit = takeDigit() else {
|
|
return nil
|
|
}
|
|
value = value * 10 + digit
|
|
}
|
|
return value
|
|
}
|
|
|
|
/// `T`, `t`, or one-or-more spaces/tabs between date and time.
|
|
mutating func takeTimeSeparator() -> Bool {
|
|
if take("T") || take("t") {
|
|
return true
|
|
}
|
|
guard first == " " || first == "\t" else {
|
|
return false
|
|
}
|
|
takeWhitespace()
|
|
return true
|
|
}
|
|
|
|
mutating func takeWhitespace() {
|
|
while first == " " || first == "\t" {
|
|
removeFirst()
|
|
}
|
|
}
|
|
|
|
mutating func takeSign() -> Int? {
|
|
if take("+") {
|
|
return 1
|
|
}
|
|
if take("-") {
|
|
return -1
|
|
}
|
|
return nil
|
|
}
|
|
}
|