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
This commit is contained in:
Mihaela Mihaljevic 2026-07-12 14:20:23 +02:00
parent 8df3f0b8d4
commit e1c08260c4
6 changed files with 448 additions and 1 deletions

View File

@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Standard-tag typed accessors on `Tagged.Scalar` (issue #11):
`timestampValue` parses a `!!timestamp` scalar under the YAML 1.1
timestamp grammar into the new pure-Swift `Tagged.Timestamp` (calendar
components, timezone offset, and `secondsSinceEpoch` via the
days-from-civil closed form; epoch expectations in the tests are
measured against Python's independent implementation), and
`binaryValue` base64-decodes a `!!binary` scalar into `[UInt8]`,
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 one-liners
the caller owns: `Date(timeIntervalSince1970:)` and `Data(_:)`.
`!!set`/`!!omap` are deliberately skipped: `Model.Mapping` already
preserves order and duplicates without a special tag.
- `Emitting.Options` gains `canonical`, `explicitDocumentStart`,
`explicitDocumentEnd`, and `lineWidth`, completing issue #10 (the
earlier `indentWidth`/`sortKeys` slice deliberately deferred these).

View File

@ -0,0 +1,84 @@
public extension PureYAML.Tagged.Scalar {
/// The scalar's `!!timestamp` value: non-`nil` when the scalar carries
/// the standard timestamp tag and its raw content parses under the
/// YAML 1.1 timestamp grammar. Bridge to a Foundation date with
/// `Date(timeIntervalSince1970: timestampValue!.secondsSinceEpoch)`;
/// the package itself stays Foundation-free.
var timestampValue: PureYAML.Tagged.Timestamp? {
guard tag == .timestamp else {
return nil
}
return PureYAML.Tagged.Timestamp(yaml: rawValue)
}
/// The scalar's `!!binary` value: non-`nil` when the scalar carries the
/// standard binary tag and its raw content is valid base64 (whitespace
/// and line breaks between groups are ignored, matching base64 content
/// spread over a block scalar). Bridge to Foundation with
/// `Data(binaryValue!)`; the package itself stays Foundation-free.
var binaryValue: [UInt8]? {
guard tag == .binary else {
return nil
}
return PureYAML.Tagged.Scalar.decodeBase64(rawValue)
}
/// Pure-Swift base64 decoding over the standard alphabet with `=`
/// padding. Whitespace is skipped; any other non-alphabet character
/// rejects the whole input rather than decoding a prefix.
static func decodeBase64(_ text: String) -> [UInt8]? {
var bytes: [UInt8] = []
var buffer = 0
var bitCount = 0
var padding = 0
for character in text {
if character == " " || character == "\n" || character == "\t" || character == "\r" {
continue
}
if character == "=" {
padding += 1
continue
}
guard padding == 0, let value = base64Value(of: character) else {
return nil
}
buffer = (buffer << 6) | value
bitCount += 6
if bitCount >= 8 {
bitCount -= 8
bytes.append(UInt8((buffer >> bitCount) & 0xFF))
}
}
// Leftover bits must be zero-padding from the final group, and the
// group structure must be consistent: 8 leftover bits (two missing
// characters) pairs with two `=`, 4 bits with one, 0 with none.
let expectedPadding = bitCount == 4 ? 2 : bitCount == 2 ? 1 : 0
guard bitCount != 6, padding == expectedPadding || padding == 0 else {
return nil
}
guard buffer & ((1 << bitCount) - 1) == 0 || bitCount == 0 else {
return nil
}
return bytes
}
private static func base64Value(of character: Character) -> Int? {
guard let ascii = character.asciiValue else {
return nil
}
switch ascii {
case 65 ... 90:
return Int(ascii - 65)
case 97 ... 122:
return Int(ascii - 97) + 26
case 48 ... 57:
return Int(ascii - 48) + 52
case 43:
return 62
case 47:
return 63
default:
return nil
}
}
}

View File

@ -0,0 +1,241 @@
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
}
}

View File

@ -0,0 +1,108 @@
@testable import PureYAML
import Testing
/// The standard-tag typed accessors (issue #11): `!!timestamp` scalars parse
/// into `Tagged.Timestamp` and `!!binary` scalars base64-decode into bytes,
/// with no caller-registered constructor. Expected epoch values are
/// measured against Python's `datetime.timestamp()`, an independent
/// implementation, not derived from the code under test.
@Suite("Tagged built-in timestamp and binary accessors")
struct TaggedBuiltinScalarTests {
func scalar(_ source: String) throws -> PureYAML.Tagged.Scalar {
let node = try PureYAML.parseTagged(source)
guard case let .mapping(mapping) = node, case let .scalar(scalar)? = mapping["v"] else {
throw PureYAML.Parsing.ParseError.unexpectedEvent(
expected: "scalar mapping value",
actual: "other",
line: 1,
column: 1,
)
}
return scalar
}
@Test("The canonical timestamp form parses with UTC and fraction")
func test_canonicalTimestamp() throws {
let stamp = try #require(scalar("v: !!timestamp 2001-12-15T02:59:43.1Z\n").timestampValue)
#expect(stamp.year == 2001)
#expect(stamp.month == 12)
#expect(stamp.day == 15)
#expect(stamp.hour == 2)
#expect(stamp.offsetSeconds == 0)
#expect(stamp.secondsSinceEpoch == 1_008_385_183.1)
}
@Test("A space-separated timestamp with a bare negative offset parses")
func test_spaceSeparatedTimestampWithOffset() throws {
let stamp = try #require(scalar("v: !!timestamp 2001-12-14 21:59:43.10 -5\n").timestampValue)
#expect(stamp.offsetSeconds == -5 * 3600)
#expect(stamp.secondsSinceEpoch == 1_008_385_183.1)
}
@Test("A date-only timestamp parses as midnight UTC")
func test_dateOnlyTimestamp() throws {
let stamp = try #require(scalar("v: !!timestamp 2002-12-14\n").timestampValue)
#expect(stamp.secondsSinceEpoch == 1_039_824_000.0)
}
@Test("A pre-epoch timestamp yields a negative epoch offset")
func test_preEpochTimestamp() throws {
let stamp = try #require(scalar("v: !!timestamp 1969-07-20T20:17:00Z\n").timestampValue)
#expect(stamp.secondsSinceEpoch == -14_182_980.0)
}
@Test("Calendar-invalid and malformed timestamps return nil")
func test_invalidTimestamps() throws {
#expect(try scalar("v: !!timestamp 2001-02-30\n").timestampValue == nil)
#expect(try scalar("v: !!timestamp 2001-13-01\n").timestampValue == nil)
#expect(try scalar("v: !!timestamp 2001-12-15T25:00:00Z\n").timestampValue == nil)
#expect(try scalar("v: !!timestamp not-a-date\n").timestampValue == nil)
#expect(try scalar("v: !!timestamp 2001-12-15T02:59:43.1Z extra\n").timestampValue == nil)
}
@Test("An untagged date-like scalar does not auto-decode")
func test_untaggedScalarStaysUntyped() throws {
#expect(try scalar("v: 2002-12-14\n").timestampValue == nil)
#expect(try scalar("v: 2002-12-14\n").binaryValue == nil)
}
@Test("The specification's binary example decodes to the GIF header bytes")
func test_binaryDecodes() throws {
let bytes = try #require(scalar("v: !!binary R0lGODlhDAAMAIQAAP8AAA==\n").binaryValue)
#expect(Array(bytes.prefix(8)) == [71, 73, 70, 56, 57, 97, 12, 0])
}
@Test("Binary content spread over a block scalar decodes")
func test_binaryBlockScalar() throws {
let source = "v: !!binary |\n R0lGODlh\n DAAMAIQAAP8AAA==\n"
let bytes = try #require(scalar(source).binaryValue)
#expect(Array(bytes.prefix(8)) == [71, 73, 70, 56, 57, 97, 12, 0])
}
@Test("Unpadded base64 decodes and corrupt base64 returns nil")
func test_binaryEdgeForms() throws {
#expect(try scalar("v: !!binary R0lGODlhDAAMAIQAAP8AAA\n").binaryValue != nil)
#expect(try scalar("v: !!binary AAAA\n").binaryValue == [0, 0, 0])
#expect(try scalar("v: !!binary not@base64!\n").binaryValue == nil)
#expect(try scalar("v: !!binary A\n").binaryValue == nil)
#expect(try scalar("v: !!binary AB==CD\n").binaryValue == nil)
}
@Test("Timestamp and binary scalars round-trip through dump with tags intact")
func test_taggedScalarsRoundTrip() throws {
let source = "when: !!timestamp 2001-12-15T02:59:43.1Z\nblob: !!binary R0lGODlhDAAMAIQAAP8AAA==\n"
let node = try PureYAML.parseTagged(source)
let reparsed = try PureYAML.parseTagged(PureYAML.dump(node))
#expect(reparsed == node)
guard case let .mapping(mapping) = reparsed else {
Issue.record("expected mapping")
return
}
guard case let .scalar(when)? = mapping["when"], case let .scalar(blob)? = mapping["blob"] else {
Issue.record("expected scalars")
return
}
#expect(when.timestampValue?.secondsSinceEpoch == 1_008_385_183.1)
#expect(blob.binaryValue?.prefix(4) == [71, 73, 70, 56])
}
}

View File

@ -105,7 +105,7 @@ explicit fallback value tree. This avoids silent compatibility drift.
| Unsupported built-in tags such as `!!timestamp`, `!!binary`, `!!set`, and `!!omap` | `parse(_:)` keeps parseable value trees without special tag construction. `parseTagged(_:)` preserves the tags, and tagged validation reports them as unsupported built-in tags. | `UnsupportedYAMLGapsFixtures.fallbackValues`, `TaggedCompatibilityTests` |
| Sexagesimal-style scalars and date-looking plain scalars | Keep string values for currently unsupported spellings such as `1:20`, `2002-04-28`, and `09`. | `ScalarCompatibilityFixtures.unsupportedScalars` |
| Custom resolvers and environment expansion | Not implemented. Build project-specific conversion on top of `Model.Value`, tagged nodes, typed `Codable`, or `PureYAML.Tagged.Constructor`. | Future compatibility work |
| Foundation-specific scalar conversions such as `Data`, `Date`, or `URL` strategies | Not special-cased in the library target. Tags stay visible through `parseTagged(_:)`; callers own conversion. | `TaggedCompatibilityTests` |
| Foundation-specific scalar conversions such as `Data`, `Date`, or `URL` strategies | `Tagged.Scalar.timestampValue` parses `!!timestamp` into the pure-Swift `Tagged.Timestamp` (bridge with `Date(timeIntervalSince1970: stamp.secondsSinceEpoch)`) and `Tagged.Scalar.binaryValue` base64-decodes `!!binary` into `[UInt8]` (bridge with `Data(bytes)`). The package stays Foundation-free; `URL` and other conversions remain caller-owned. | `TaggedBuiltinScalarTests` |
## Migration Checklist

View File

@ -96,3 +96,4 @@ PureYAML.Streaming excluded caseless namespace; holds no instances or state
PureYAML.Streaming.Outcome excluded closed result enum; illegal states unrepresentable
PureYAML.Streaming.Scanner excluded resumable scanning engine; not a validated subject
PureYAML.Streaming.Token excluded data-transfer scanned token
PureYAML.Tagged.Timestamp excluded data-transfer calendar components; parse rejects invalid states

Can't render this file because it has a wrong number of fields in line 3.