Seven correctness fixes to the recently landed anchors/styles, canonical, timestamp/binary, and full-node-key work, each reproduced and covered by a regression test. - Stream dump doubled the `---` marker under explicitDocumentStart or canonical: the single-document body now renders marker-free and the stream path owns marker emission, so a two-document stream reparses as two documents, not four. - Tagged flow-mapping keys used block-context safety, so a key like `a,b` emitted bare and reparsed as several entries; flow keys now use the flow-aware plain-scalar check. - Plain scalars ending in `:` reparse as an empty-valued mapping key, so both the model and tagged emit paths now fall back to quoting them. - An alias mapping key pointing at a defined non-scalar anchor raised the misleading undefinedAlias; it now raises expectedScalarKey, and a truly undefined alias key still raises undefinedAlias. - Tagged duplicate-key detection keyed on source spelling: it now keys on the resolved scalar value plus effective tag, so 0x1 and 1 collide while 1 and "1" do not and custom-tagged keys stay distinct. - Dumping a tree nested past the emitter limit overflowed the stack. - Timestamp now accepts a leap-second value of 60, counted as 60 elapsed seconds in the epoch math. - Tagged.Dumper honors sortKeys in its block and canonical paths for all-plain-string-key mappings, matching the model dumper. Nesting-guard design: dump is public and non-throwing across 90+ call sites, so making it throwing would be a breaking change. A tree nested deeper than the parser's own maxNestingDepth cannot round-trip through PureYAML (the parser rejects it), so no faithful emission exists. Each dump entry point now measures depth iteratively (no recursion, so the guard itself cannot overflow) and, past the limit, returns a documented, reparseable marker document that resolves to null. Under the limit the existing recursive renderers run byte-identically, so parsed trees and every realistic hand-built tree are unaffected. This mirrors the parser's enforceNestingLimit, which bounds depth before any recursive consumer.
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 <= 60
|
|
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
|
|
}
|
|
}
|