From e1c08260c4747d73247eac2ad164010571461e9f Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sun, 12 Jul 2026 14:20:23 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 13 ++ Sources/Tagged/Tagged.Scalar.Builtins.swift | 84 +++++++ Sources/Tagged/Tagged.Timestamp.swift | 241 ++++++++++++++++++++ Tests/TaggedBuiltinScalarTests.swift | 108 +++++++++ docs/MIGRATION.md | 2 +- scripts/validation-coverage.tsv | 1 + 6 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 Sources/Tagged/Tagged.Scalar.Builtins.swift create mode 100644 Sources/Tagged/Tagged.Timestamp.swift create mode 100644 Tests/TaggedBuiltinScalarTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0195f4c..878e726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/Sources/Tagged/Tagged.Scalar.Builtins.swift b/Sources/Tagged/Tagged.Scalar.Builtins.swift new file mode 100644 index 0000000..9c97046 --- /dev/null +++ b/Sources/Tagged/Tagged.Scalar.Builtins.swift @@ -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 + } + } +} diff --git a/Sources/Tagged/Tagged.Timestamp.swift b/Sources/Tagged/Tagged.Timestamp.swift new file mode 100644 index 0000000..7ab0087 --- /dev/null +++ b/Sources/Tagged/Tagged.Timestamp.swift @@ -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 + } +} diff --git a/Tests/TaggedBuiltinScalarTests.swift b/Tests/TaggedBuiltinScalarTests.swift new file mode 100644 index 0000000..435cb7d --- /dev/null +++ b/Tests/TaggedBuiltinScalarTests.swift @@ -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]) + } +} diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 3a804f8..3a02d18 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -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 diff --git a/scripts/validation-coverage.tsv b/scripts/validation-coverage.tsv index 270ab43..0cb300c 100644 --- a/scripts/validation-coverage.tsv +++ b/scripts/validation-coverage.tsv @@ -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