PureYAML/Sources/Tagged/Tagged.Timestamp.swift
Mihaela Mihaljevic e1c08260c4 feat(tagged): add typed timestamp and binary scalar accessors
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
2026-07-12 14:20:23 +02:00

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
}
}