From beb1f17691ba2c5188379015999f04914dadde89 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Thu, 28 May 2026 14:35:57 +0200 Subject: [PATCH 1/4] Modernization --- .github/workflows/ci.yml | 43 ++ .gitignore | 5 +- .spi.yml | 4 + .../contents.xcworkspacedata | 7 - NOTES.md | 139 ++++++ Package.swift | 12 +- README.md | 430 +++++++++--------- Sources/DataKit/Builder/DataBuilder.swift | 19 + .../DataKit/Builder/FormatBuilder+Read.swift | 19 +- .../Builder/FormatBuilder+ReadWrite.swift | 17 +- .../DataKit/Builder/FormatBuilder+Write.swift | 17 +- Sources/DataKit/Builder/FormatBuilder.swift | 14 +- .../DataKit/Conversions/Conversion+Cast.swift | 23 +- .../Conversions/Conversion+Clamped.swift | 13 +- .../Conversions/Conversion+Encoding.swift | 19 +- .../Conversions/Conversion+Exactly.swift | 25 +- .../Conversions/Conversion+KeyPath.swift | 15 +- .../DataKit/Conversions/Conversion+Map.swift | 11 +- .../Conversions/Conversion+Measurement.swift | 11 +- .../Conversions/Conversion+PrefixCount.swift | 21 +- .../Conversions/Conversion+Reversible.swift | 29 +- .../Conversion+VariableCount.swift | 27 +- Sources/DataKit/Conversions/Conversion.swift | 36 +- Sources/DataKit/DataKit.docc/DataKit.md | 140 ++++++ .../Environment/Environment+Endianness.swift | 27 +- .../Environment/Environment+Suffix.swift | 29 +- .../Environment/EnvironmentValues.swift | 39 +- .../SkipChecksumVerification.swift | 16 +- Sources/DataKit/Error.swift | 30 +- Sources/DataKit/Exports.swift | 7 +- Sources/DataKit/Property/Checksum.swift | 58 ++- Sources/DataKit/Property/Convert.swift | 32 +- Sources/DataKit/Property/Custom.swift | 20 +- Sources/DataKit/Property/Environment.swift | 20 +- .../Property/EnvironmentProperty.swift | 23 +- Sources/DataKit/Property/KeyPath.swift | 10 +- Sources/DataKit/Property/OnRead.swift | 13 +- Sources/DataKit/Property/OnWrite.swift | 12 +- .../Property/Property+Conversion.swift | 13 +- .../DataKit/Property/Property+Custom.swift | 10 +- Sources/DataKit/Property/Property.swift | 15 +- Sources/DataKit/Property/Scope.swift | 36 +- Sources/DataKit/Property/Using.swift | 21 +- Sources/DataKit/ReadWritable/Format.swift | 16 +- .../DataKit/ReadWritable/ReadWritable.swift | 38 +- .../ReadWritable/ReadWritableProperty.swift | 17 +- Sources/DataKit/Readable/ReadContainer.swift | 45 +- Sources/DataKit/Readable/ReadContext.swift | 72 ++- Sources/DataKit/Readable/Readable.swift | 70 ++- .../DataKit/Readable/ReadableProperty.swift | 22 +- Sources/DataKit/Sendable.swift | 67 +++ .../Values/ReadWritable+FloatingPoint.swift | 14 +- .../DataKit/Values/ReadWritable+Integer.swift | 16 +- .../Values/ReadWritable+Optional.swift | 16 +- Sources/DataKit/Values/ReadWritable+Raw.swift | 11 +- Sources/DataKit/Writable/Writable.swift | 48 +- .../DataKit/Writable/WritableProperty.swift | 20 +- Sources/DataKit/Writable/WriteContainer.swift | 28 +- 58 files changed, 1499 insertions(+), 528 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .spi.yml delete mode 100644 .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 NOTES.md create mode 100644 Sources/DataKit/DataKit.docc/DataKit.md create mode 100644 Sources/DataKit/Sendable.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ca3425 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + macos: + name: macOS (Swift ${{ matrix.swift }}) + runs-on: macos-14 + strategy: + matrix: + swift: ["5.9", "5.10"] + steps: + - uses: actions/checkout@v4 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Show toolchain + run: swift --version + - name: Build + run: swift build -v + - name: Test + run: swift test -v + + linux: + name: Linux (Swift ${{ matrix.swift }}) + runs-on: ubuntu-latest + strategy: + matrix: + swift: ["5.9", "5.10"] + steps: + - uses: actions/checkout@v4 + - uses: swift-actions/setup-swift@v2 + with: + swift-version: ${{ matrix.swift }} + - name: Show toolchain + run: swift --version + - name: Build + run: swift build -v + - name: Test + run: swift test -v diff --git a/.gitignore b/.gitignore index 330d167..587cacd 100644 --- a/.gitignore +++ b/.gitignore @@ -44,10 +44,13 @@ playground.xcworkspace # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project -# .swiftpm +.swiftpm/ .build/ +# DocC build output +*.doccarchive/ + # CocoaPods # # We recommend against adding the Pods directory to your .gitignore. However diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..3be2400 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [DataKit] diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..1d6b2ba --- /dev/null +++ b/NOTES.md @@ -0,0 +1,139 @@ +# Feature ideas + +Drafts of feature ideas surfaced during the 2026 audit. Each entry is sized for a single +GitHub issue when there is appetite to act on it. None of these are committed work — the +audit explicitly scoped this maintenance pass to docs, packaging, README, and Sendable. + +## 1. Error context enrichment + +**What.** Wrap thrown errors at every `FormatProperty` boundary with the current byte +offset, the `Scope`/`Using`/`KeyPath` breadcrumb path, and a reference to the underlying +error. Surface as a public `DataKitError` struct with `.byteOffset`, `.path`, and +`.underlyingError`. Today errors like `LengthExceededError`, `ConversionError`, and +`ReadContext.ValueDoesNotExistError` are positionless — almost useless for debugging a +real-world wire-protocol failure on byte 47 of a 200-byte packet. + +**Sketch.** +```swift +do { + let v = try Packet(data) +} catch let error as DataKitError { + print(error.byteOffset) // 47 + print(error.path) // "Packet.payload -> Scope -> Convert(\.temperature)" + throw error.underlyingError // the original LengthExceededError +} +``` + +**Feasibility.** Medium. Every existing `FormatProperty` site that throws must be +touched. Risk of double-wrapping when errors propagate through several layers; add an +"already enriched" check. No public-API breakage. + +**Prior art.** `construct.py` `ConstructError.stream_position`, +`swift-binary-parsing.ParsingError.location`. + +## 2. `Bounded` / `FixedLength` sub-format + +**What.** Run a child format inside an explicit byte budget. On read, assert the child +consumed *exactly* N bytes (or pad with a fill byte if `mode: .padding`). On write, +assert the child produced N (or pad). Builds directly on `Scope` — most of the read-side +code already exists there. + +**Sketch.** +```swift +Bounded(length: 16, padding: 0x00) { + Convert(\.name) { $0.encoded(.utf8).dynamicCount } + .suffix(0 as UInt8, isRequired: false) +} + +// Or length read from another keyPath: +\.payloadLength +Bounded(length: \.payloadLength) { + \.payload +} +``` + +**Feasibility.** Easy. ~80% of the implementation is already in +`Sources/DataKit/Property/Scope.swift` — just add an `expectedLength: Int?` and +post-check `container.index` movement. + +**Prior art.** Kaitai Struct `size:`; construct.py `FixedSized` / `Padded`. + +## 3. `BitField` / `Bits` primitive + +**What.** Open a "bit cursor" inside the byte stream and read/write N-bit fields. Today +the only sub-byte expression is an `OptionSet`, which works only for one-bit-per-field +boolean flags. Real protocols (BLE, IPv4 header, H.264 NALU, MIDI) regularly pack +multi-bit fields into one byte. + +**Sketch.** +```swift +BitField { // consumes whole bytes; must end byte-aligned + Bits(\.version, width: 4) // top nibble + Bits(\.headerLength, width: 4) + Bits(\.dscp, width: 6) + Bits(\.ecn, width: 2) +} +.bitOrder(.msbFirst) +``` + +**Feasibility.** Medium. Needs `BitReadContainer` / `BitWriteContainer` wrappers and a +decision on MSB-first vs LSB-first bit order. Hardest design choice: error vs pad when a +block doesn't end on a byte boundary. No existing-code rewrites. + +**Prior art.** Kaitai `b3`, `b13`; construct.py `BitStruct` / `BitsInteger`. + +## 4. `DataKitTesting` round-trip helpers (new target) + +**What.** A `DataKitTesting` library target shipping `assertRoundTrip(_:bytes:)` and a +diff-friendly hex-dump on failure. The round-trip invariant is the central correctness +contract of `ReadWritable`; every consumer reinvents this same test today. + +**Sketch.** +```swift +import DataKitTesting + +func testWeatherPacketRoundTrip() throws { + try assertRoundTrip( + WeatherStationUpdate(features: [.hasTemperature], ...), + bytes: [0x02, 0x01, 0x42, ...] + ) +} +``` + +**Feasibility.** Easy. Mostly: write, read back, compare; re-write, compare bytes. New +target in `Package.swift` so it doesn't bloat the runtime library. + +**Prior art.** Pattern is widely used (`swift-snapshot-testing` philosophy, +QuickCheck-style property tests). + +## 5. More conversions: LEB128, ZigZag varint, Q-format, half-float + +**What.** Add the obvious missing numeric encodings to `Sources/DataKit/Conversions/`: + +- LEB128 / ULEB128 (DWARF, Wasm, Protocol Buffers varints). +- ZigZag int↔uint (Protobuf `sint32` / `sint64`). +- Q-format fixed-point (Q15.16, Q7.8, etc. — DSP/audio/BLE). +- ASN.1 BER length octets (LDAP, SNMP, TLS certs). + +`Float16` is already a `ReadWritable` conformance on arm64; consider a software fallback +for x86_64 if there is demand. + +**Feasibility.** Easy each, but LEB128's variable byte count on write needs care. Same +pattern as `Conversion+PrefixCount.swift`. Can be shipped one PR at a time. + +**Prior art.** Every binary-protocol library ships varints; Foundation does not. + +--- + +## Out-of-scope ideas mentioned in the audit but not pursued + +- **Async streaming reads** from `AsyncSequence` / `InputStream`. Hard — collides + with random-access-data assumptions in `Scope` and `Checksum`. Defer until concrete + demand. +- **`@ReadWritable` macro** that synthesizes `format` + `init(from:)` from declared + properties. Substantial implementation effort; worth reconsidering once Apple's + `swift-binary-parsing` story shakes out. +- **`OneOf` discriminated-union primitive.** Useful for TLV-style protocols but requires + a non-trivial type-erasure design. +- **Hex-dump / debug pretty-printer.** Strong UX win, but depends on the error-context + feature (#1) for the field-to-byte mapping. diff --git a/Package.swift b/Package.swift index 0d62ccb..786c306 100644 --- a/Package.swift +++ b/Package.swift @@ -1,9 +1,15 @@ -// swift-tools-version: 5.4 +// swift-tools-version: 5.9 import PackageDescription let package = Package( name: "DataKit", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6), + ], products: [ .library( name: "DataKit", @@ -21,6 +27,10 @@ let package = Package( name: "DataKit", dependencies: [ .product(name: "CRC", package: "crc-swift"), + ], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + .enableUpcomingFeature("ExistentialAny"), ] ), .testTarget( diff --git a/README.md b/README.md index c80aacb..11855b9 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,117 @@ +![DataKit](https://github.com/QuickBirdEng/DataKit/assets/15239005/2b8fc619-2c29-4900-984b-9187ae7a5b57) -![‎DataKit](https://github.com/QuickBirdEng/DataKit/assets/15239005/2b8fc619-2c29-4900-984b-9187ae7a5b57) +**A declarative DSL for binary protocols in Swift.** -DataKit offers a modern, intuitive and declarative interface for reading and writing binary formatted data in Swift. +- Round-trip reads ↔ writes from a single format declaration. +- Built on Swift result builders — feels like SwiftUI, but produces bytes. +- Handles real-world wire-protocol concerns: endianness, bit-packed flags, length prefixes, dynamic suffixes, and CRC checksums. -## 🏃‍♂️Getting started +[![Swift Package Manager](https://img.shields.io/badge/SwiftPM-compatible-brightgreen.svg)](https://swift.org/package-manager) +[![Swift](https://img.shields.io/badge/Swift-5.9%2B-orange.svg)](https://swift.org) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Documentation](https://swiftpackageindex.com/QuickBirdEng/DataKit/documentation)](https://swiftpackageindex.com/QuickBirdEng/DataKit/documentation) -As an introduction into how this library can be used to make working with binary formatted data easier, let me first introduce you to the type, we are going to read/write. Let's assume we are building a weather station and we are using the following type(s) to give updates about the currently measured values: +--- -```swift -struct WeatherStationFeatures: OptionSet, ReadWritable { - var rawValue: UInt8 +## Contents - static var hasTemperature = Self(rawValue: 1 << 0) - static var hasHumidity = Self(rawValue: 1 << 1) - static var usesMetricUnits = Self(rawValue: 1 << 2) -} +- [Installation](#installation) +- [Quick start](#quick-start) +- [Real-world example: weather-station packet](#real-world-example-weather-station-packet) +- [Concepts](#concepts) +- [Built-in conformances](#built-in-conformances) +- [Requirements](#requirements) +- [Contributing](#contributing) +- [License](#license) -struct WeatherStationUpdate { +## Installation - var features: WeatherStationFeatures - var temperature: Measurement - var humidity: Double +Add DataKit to your `Package.swift`: -} +```swift +dependencies: [ + .package(url: "https://github.com/QuickBirdEng/DataKit.git", from: "0.1.0"), +], ``` -The encoded format should be: -- Each message starts with a byte with the value 0x02. -- The following byte contains multiple feature flags: - - bit 0 is set: Using °C instead of °F for the temperature - - bit 1 is set: The message contains temperature information - - bit 2 is set: The message contains humidity information -- Temperature as a big-endian 32-bit floating-point number -- Relative Humidity as UInt8 in the range of [0, 100] -- CRC-32 with the default polynomial for the whole message (incl. 0x02 prefix). - -### Writing data +…and add `"DataKit"` to the dependencies list of any target that uses it. Xcode users can +also add the package via *File → Add Package Dependencies…*. -You have two options for converting the above type `WeatherStationUpdate` into data: A `DataBuilder` or the `Writable` protocol. If you intend to both read and write the data - make sure to read the `Reading & Writing data` section - -#### DataBuilder - -A `DataBuilder` provides you with a very simple and limited interface. Using the power of result builders, you can simply state the values to be written in a given order and `DataBuilder` will take over all the work to encode the values and append the individual bytes to form a `Data` object. `DataBuilder` is always expected to return a `Data` object without throwing errors, which is why conversion are not supported here - You might want to have a look at the `Writable` protocol then! +## Quick start ```swift -extension WeatherStationUpdate { +import DataKit - @DataBuilder var data: Data { - UInt8(0x02) - features - if features.contains(.hasTemperature) { - Float(temperature.converted(to: features.contains(.usesMetricUnits) ? .celsius : .fahrenheit).value) - } - if features.contains(.hasHumidity) { - UInt8(humidity * 100) - } - CRC32.default +struct Header: ReadWritable { + var magic: UInt16 + var count: UInt8 + + init(from context: ReadContext
) throws { + magic = try context.read(for: \.magic) + count = try context.read(for: \.count) } + static var format: Format { + \.magic + \.count + } } -``` -With this addition, you can easily get the data of this object using its `data` property. - -#### Writable - -With the power of keyPaths and result builders, you can also write your objects into `Data` using the `Writable` protocol and its `writeFormat` property. Simply state out individual fixed values (e.g. byte prefixes), keyPaths with `Writable` values or other constructs that are further explained in the `Extras` section of this document. - -```swift -extension WeatherStationUpdate: Writable { - - static var writeFormat: WriteFormat { - Scope { - UInt8(0x02) - - \.features +let header = try Header(data) // decode +let bytes = try header.write() // encode +``` - Using(\.features) { features in - if features.contains(.hasTemperature) { - let unit: UnitTemperature = - features.contains(.usesMetricUnits) ? .celsius : .fahrenheit - Convert(\.temperature) { - $0.converted(to: unit).cast(Float.self) - } - } - if features.contains(.hasHumidity) { - Convert(\.humidity) { - Double($0) / 100 - } writing: { - UInt8($0 * 100) - } - } - } +That's everything: one `format` declaration drives both directions. Key paths in +`format` and `init(from:)` must match — see the [reading footgun](#-heads-up-keypath-mismatch-is-a-runtime-error) +below. - CRC32.default - } - .endianness(.big) - } +## Real-world example: weather-station packet -} -``` +A more realistic example exercises feature flags, conditional fields, conversion, and a +CRC trailer. Here is the on-the-wire layout we will model: -By conforming to the `Writable` protocol, you are now able to simply call its `write` function to write its data out: +- Frame prefix: one byte `0x02`. +- One feature-flags byte: + - bit 0: temperature is present + - bit 1: humidity is present + - bit 2: temperature is in °C (otherwise °F) +- If present: temperature as a big-endian 32-bit float. +- If present: humidity as a `UInt8` in the range `[0, 100]`, exposed as a `Double` in + `[0, 1]`. +- A CRC-32 over the entire frame. ```swift -let message: WeatherStationUpdate = ... -let messageData = try message.write() // You can also inject a custom environment here, if needed -``` +import DataKit -### Reading data - -Supporting reading of data into objects is slightly more complicated. Conforming to the `Readable` protocol will require you to implement both an initializer to create an object from a given `ReadContext` and a static `readFormat` property describing how data is aligned. +struct WeatherStationFeatures: OptionSet, ReadWritable { + var rawValue: UInt8 -A `ReadContext` provides you with the values that have been read using the `readFormat`. Make sure to use the same keyPaths in the initializer and `readFormat` to ensure smooth reading of values. + static var hasTemperature = Self(rawValue: 1 << 0) + static var hasHumidity = Self(rawValue: 1 << 1) + static var usesMetricUnits = Self(rawValue: 1 << 2) +} -```swift -extension WeatherStationUpdate: Readable { +struct WeatherStationUpdate: ReadWritable { + var features: WeatherStationFeatures + var temperature: Measurement + var humidity: Double - init(from context: ReadContext) throws { + init(from context: ReadContext) throws { features = try context.read(for: \.features) - temperature = try context.readIfPresent(for: \.temperature) ?? .init(value: .nan, unit: .kelvin) + temperature = try context.readIfPresent(for: \.temperature) + ?? .init(value: .nan, unit: .kelvin) humidity = try context.readIfPresent(for: \.humidity) ?? .nan } - static var readFormat: ReadFormat { + static var format: Format { Scope { - UInt8(0x02) - + UInt8(0x02) // asserted on read, emitted on write \.features Using(\.features) { features in if features.contains(.hasTemperature) { let unit: UnitTemperature = - features.contains(.usesMetricUnits) ? .celsius : .fahrenheit + features.contains(.usesMetricUnits) ? .celsius : .fahrenheit Convert(\.temperature) { $0.converted(to: unit).cast(Float.self) } @@ -146,185 +125,206 @@ extension WeatherStationUpdate: Readable { } } - CRC32.default + CRC32.default // covers exactly the Scope's bytes } .endianness(.big) } - } -``` -By implementing all these requirements of the `Readable` protocol, you now gain another initializer `init(_: Data) throws` to read objects from `Data` objects: - -```swift -let data: Data = ... -let message = try WeatherStationUpdate(data) // You can also inject a custom environment here, if needed +let packet: WeatherStationUpdate = ... +let bytes = try packet.write() +let decoded = try WeatherStationUpdate(bytes) ``` -### Reading & Writing data +> If you only need one direction, conform to ``Readable`` or ``Writable`` and replace +> `format` with `readFormat` (plus the `init(from:)`) or `writeFormat`. -To make a type both `Readable` and `Writable`, you can conform your type to the `ReadWritable` protocol. Instead of providing a separate format for reading and writing, you can define a `Format` property that is used for both reading and writing. For our example type, we can simply merge the two formats into one and provide the initializer for creating an object from a given `ReadContext`. +### ⚠️ Heads-up: keypath mismatch is a runtime error -```swift -extension WeatherStationUpdate: ReadWritable { +The format walk stores each parsed value into a `ReadContext` keyed by a `KeyPath`. Your `init(from:)` retrieves it by the same key path. A mismatch (typo, renamed +field, or stale code) surfaces as `ReadContext.ValueDoesNotExistError` *at runtime* — the +compiler cannot catch it. Add a round-trip test for every `ReadWritable` to flush these +out early. - init(from context: ReadContext) throws { - features = try context.read(for: \.features) - temperature = try context.readIfPresent(for: \.temperature) ?? .init(value: .nan, unit: .kelvin) - humidity = try context.readIfPresent(for: \.humidity) ?? .nan - } - - static var format: Format { - Scope { - UInt8(0x02) +## Concepts - \.features +| Concept | When to reach for it | Source | +|---|---|---| +| [`Property`](#property) | Help the compiler with a key path it cannot infer; entry-point for fluent operators. | [`Property.swift`](Sources/DataKit/Property/Property.swift) | +| [`Convert`](#convert) | Encode a field as a different on-wire type than the in-memory type. | [`Convert.swift`](Sources/DataKit/Property/Convert.swift) | +| [`Custom`](#custom) | Drop into raw `ReadContainer` / `WriteContainer` access. | [`Custom.swift`](Sources/DataKit/Property/Custom.swift) | +| [`Using`](#using) | Branch on a value already in the context (e.g. feature flags, length prefixes). | [`Using.swift`](Sources/DataKit/Property/Using.swift) | +| [`Scope`](#scope) | Restrict checksum coverage / sub-buffer reads to a sub-range. | [`Scope.swift`](Sources/DataKit/Property/Scope.swift) | +| [`Environment`](#environment) | Read ambient state (endianness, suffix terminator, etc.) during the walk. | [`Environment/`](Sources/DataKit/Environment/) | +| [`Conversion` / `ReversibleConversion`](#conversion--reversibleconversion) | Reusable bidirectional encoders (UTF-8, prefix-count, etc.). | [`Conversions/`](Sources/DataKit/Conversions/) | +| [`ChecksumProperty`](#checksums) | CRC and custom checksum fields via [crc-swift](https://github.com/QuickBirdEng/crc-swift). | [`Checksum.swift`](Sources/DataKit/Property/Checksum.swift) | - Using(\.features) { features in - if features.contains(.hasTemperature) { - let unit: UnitTemperature = - features.contains(.usesMetricUnits) ? .celsius : .fahrenheit - Convert(\.temperature) { - $0.converted(to: unit).cast(Float.self) - } - } - if features.contains(.hasHumidity) { - Convert(\.humidity) { - Double($0) / 100 - } writing: { - UInt8($0 * 100) - } - } - } +### Property - CRC32.default - } - .endianness(.big) - } +`Property` wraps a key path so the compiler can resolve the root type, and is the +entry-point for the fluent `.conversion { ... }` / `.read(...)` / `.write(...)` modifiers +that ultimately build a `Convert` or `Custom`. -} +```swift +Property(\.id) +Property(\.payload).conversion { $0.exactly(UInt16.self) } ``` -Hooray, you can now read and write your objects! 🎉 +A bare key path (e.g. `\.id`) inside a builder is equivalent to `Property(\.id)`. -## 🤸‍♂️ Extras +### Convert -Reading/Writing data is often quite complicated and different format pose different challenges to minimize payloads, reduce bandwidth, improve performance, etc. To make it easy to handle different common scenarios, `DataKit` provides a couple of extra features to handle the most common challenges. +`Convert` is the bridge between a model's Swift type and the wire's bytes. Three forms: +a `Conversion` builder, paired raw closures, or — for `ReadWritable` — a +`ReversibleConversion`. -### ✏️ Convert / Custom / Property +```swift +Convert(\.string) { // C string: UTF-8, 0-terminated + $0.encoded(.utf8).dynamicCount +} +.suffix(0 as UInt8) -In some special cases, you might need more control over how data is read/written. For these cases, the wrappers `Custom`, `Convert` and `Property` might be of interest. +Convert(\.length) { // Pascal short string + $0.encoded(.ascii).prefixCount(UInt8.self) +} -- `Property` makes it easy to wrap a keyPath, if the Root type may not be recognized by the compiler. You can further use functions on it to map a `Property` to either a `Custom` or `Convert` wrapper. -- `Convert` allows you to convert a keyPath's value before reading/writing it. Oftentimes, this is very usefuly for sequence values with variable size. You can either provide custom conversion methods directly or use a pre-existing `Conversion`/`ReversibleConversion` value. -- `Custom` allows you to access the raw reading/writing functionality with direct access to the `ReadContainer`/`WriteContainer` and respective context values. If you need the read/write behavior more than once in your codebase, you might want to have a look at conversions though. +Convert(\.humidity) { // Double <-> wire UInt8 (0...100) + Double($0) / 100 +} writing: { + UInt8($0 * 100) +} +``` -### 💱 Conversion / ReversibleConversion +### Custom -For some types, there is not a single "correct" format (e.g. thinking about Pascal vs C strings), which is why `DataKit` uses so called `Conversion` values to allow for conversion to be defined once and then used multiple times. Especially helpful is the `ReversibleConversion` type that allows for conversion to be provided in both directions at the same time. +`Custom` is the escape hatch for fields that no other primitive expresses. If the same +custom logic appears more than once, lift it into a reusable `Conversion`. -Assuming our type has a `\.string` keyPath with a `String` value, you could either use a suffix 0-byte to encode the string using UTF8 (similar to C strings): ```swift -Convert(\.string) { // UTF8 string with a suffix 0-byte - $0.encoded(.utf8).dynamicCount +Custom(\.timestamp) { container in + let raw = try UInt32(from: &container) + return Date(timeIntervalSince1970: TimeInterval(raw)) +} write: { container, date in + try UInt32(date.timeIntervalSince1970).write(to: &container) } -.suffix(0 as UInt8) ``` -Or you encode the string with a prefix byte containing the byte count (similar to Pascal ShortString): +### Using + +`Using` runs a sub-format that depends on a value already in the context (or already on +the root, during a write). The workhorse for feature-flag and length-prefixed layouts. + ```swift -Convert(\.string) { // Ascii string with a prefix count byte - $0.encoded(.ascii).prefixCount(UInt8.self) +\.count // parse a count byte first +Using(\.count) { count in + for index in 0..` where `Wrapped` is `ReadWritable`. **Note:** reading always + produces `.some`; the `Optional` typing matters on the write side and for + `ReadContext.readIfPresent(for:)`. To make presence truly optional on read, gate the + read with `Using` and a flag. -Specify `https://github.com/QuickBirdEng/DataKit.git` as the package link. +All numeric encodings respect `EnvironmentValues.endianness`. The host-native default is +a footgun for cross-platform formats — set endianness explicitly at the top of your +format. -#### Manually +## Requirements -If you prefer not to use a dependency manager, you can integrate DataKit into your project manually by downloading the source code and placing the files in your project directory. +- Swift 5.9+ +- iOS 13+, macOS 10.15+, tvOS 13+, watchOS 6+, Linux (Swift 5.9+) +- Single dependency: [crc-swift](https://github.com/QuickBirdEng/crc-swift) (re-exported + as `CRC`) -## 👤 Author +Documentation is hosted at [Swift Package Index](https://swiftpackageindex.com/QuickBirdEng/DataKit/documentation). -DataKit is created with ❤️ by [QuickBird](https://quickbirdstudios.com). +## Contributing -## ❤️ Contributing +Issues and PRs welcome. Before opening a PR: -Feel free to open issues for help, found bugs or to discuss new feature requests. Happy to help! -Open a pull request, if you want to propose changes to DataKit. +- Run `swift test` from the repo root — all existing tests should pass. +- Add a round-trip test for any new format primitive or built-in conformance. +- See [`CLAUDE.md`](CLAUDE.md) for an architecture tour. -## 📃 License +## License -DataKit is released under an MIT license. See [License.md](https://github.com/QuickBirdEng/DataKit/blob/master/LICENSE) for more information. +DataKit is released under the MIT license. See [LICENSE](LICENSE). + +## Author + +DataKit is created with ❤️ by [QuickBird](https://quickbirdstudios.com). diff --git a/Sources/DataKit/Builder/DataBuilder.swift b/Sources/DataKit/Builder/DataBuilder.swift index c1132ec..9147447 100644 --- a/Sources/DataKit/Builder/DataBuilder.swift +++ b/Sources/DataKit/Builder/DataBuilder.swift @@ -1,9 +1,21 @@ +// DataBuilder.swift import Foundation +/// A simple `@resultBuilder` for assembling raw `Data` values declaratively. +/// +/// `DataBuilder` is a standalone, non-throwing helper for cases where you just want to +/// build a binary blob without a full ``Readable``/``Writable`` model — for example, to +/// supply a custom suffix via ``FormatProperty/suffix(_:isRequired:)-...``. +/// +/// **Endianness footgun.** Unlike the format DSL — which honors +/// ``EnvironmentValues/endianness`` — `DataBuilder`'s integer and floating-point +/// expressions always encode in **big-endian**. If you need a different byte order, use +/// the format DSL instead. @resultBuilder public enum DataBuilder { + /// An accumulator step. Each `Component` appends to a shared `Data` buffer when applied. public struct Component { // MARK: Stored Properties @@ -26,6 +38,8 @@ public enum DataBuilder { } } + /// Integers are encoded **big-endian**, regardless of any surrounding format + /// environment. public static func buildExpression(_ expression: I) -> Component { Component { data in withUnsafeBytes(of: expression.bigEndian) { @@ -34,10 +48,13 @@ public enum DataBuilder { } } + /// Floating-point values are encoded via their integer `bitPattern`, **big-endian**. public static func buildExpression(_ expression: F) -> Component { buildExpression(expression.bitPattern) } + /// A `RawRepresentable` whose raw value is a fixed-width integer is lifted to its + /// integer encoding. public static func buildExpression( _ expression: R ) -> Component where R.RawValue: FixedWidthInteger { @@ -68,6 +85,8 @@ public enum DataBuilder { return data } + /// A bare ``Checksum`` value computes its checksum over the buffer accumulated so far + /// and appends the result (in big-endian). public static func buildExpression(_ expression: C) -> Component { Component { data in let value = expression.calculate(for: data) diff --git a/Sources/DataKit/Builder/FormatBuilder+Read.swift b/Sources/DataKit/Builder/FormatBuilder+Read.swift index b76bc09..e6568b9 100644 --- a/Sources/DataKit/Builder/FormatBuilder+Read.swift +++ b/Sources/DataKit/Builder/FormatBuilder+Read.swift @@ -1,16 +1,15 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 25.06.23. -// +// FormatBuilder+Read.swift import Foundation +/// Result-builder typealias for read formats — the type of `@ReadBuilder`. public typealias ReadFormatBuilder = FormatBuilder> extension FormatBuilder where Root: Readable, Format == ReadFormat { + /// An `Equatable & Readable` literal in a `@ReadBuilder` block parses the corresponding + /// bytes and asserts equality with the literal — throwing ``UnexpectedValueError`` on + /// mismatch. This is how magic-number / frame-prefix assertions are expressed. public static func buildExpression(_ expression: V) -> Format { .init { container, _ in let value = try V(from: &container) @@ -20,6 +19,9 @@ extension FormatBuilder where Root: Readable, Format == ReadFormat { } } + /// A bare ``Checksum`` value in a `@ReadBuilder` block reads and verifies the checksum + /// bytes (always in big-endian). To control the input range, wrap the relevant section + /// in a ``Scope``. public static func buildExpression(_ expression: C) -> Format where C.Value: Readable { buildExpression( ReadFormat { container, _ in @@ -30,14 +32,19 @@ extension FormatBuilder where Root: Readable, Format == ReadFormat { ) } + /// Any ``ReadableProperty`` (e.g. ``Property``, ``Convert``, ``Custom``, ``Scope``) + /// participates in a read builder. public static func buildExpression(_ expression: V) -> Format where V.Root == Root { .init(read: expression.read) } + /// Bare key-path syntax (`\.field`) lifts to ``Property``. public static func buildExpression(_ expression: KeyPath) -> Format { buildExpression(Property(expression)) } + /// A sequence of `Equatable & Readable` literals reads and asserts each element in order + /// — useful for multi-byte magic-number prefixes. public static func buildExpression(_ expression: S) -> Format where S.Element: Readable & Equatable { .init(expression.map(buildExpression)) } diff --git a/Sources/DataKit/Builder/FormatBuilder+ReadWrite.swift b/Sources/DataKit/Builder/FormatBuilder+ReadWrite.swift index 3b9d7bf..4fcf381 100644 --- a/Sources/DataKit/Builder/FormatBuilder+ReadWrite.swift +++ b/Sources/DataKit/Builder/FormatBuilder+ReadWrite.swift @@ -1,16 +1,15 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 25.06.23. -// +// FormatBuilder+ReadWrite.swift import Foundation +/// Result-builder typealias for combined read+write formats — the type of `@FormatBuilder` +/// on ``ReadWritable``. public typealias ReadWriteFormatBuilder = FormatBuilder> extension FormatBuilder where Root: ReadWritable, Format == ReadWriteFormat { + /// Any value that conforms to *both* ``ReadableProperty`` and ``WritableProperty`` (e.g. + /// ``Property``, ``Scope``) participates in a read+write builder. public static func buildExpression(_ expression: V) -> Format where V.Root == Format.Root { .init( read: ReadFormatBuilder.buildExpression(expression), @@ -18,6 +17,8 @@ extension FormatBuilder where Root: ReadWritable, Format == ReadWriteFormat(_ expression: V) -> Format { .init( read: ReadFormatBuilder.buildExpression(expression), @@ -25,6 +26,7 @@ extension FormatBuilder where Root: ReadWritable, Format == ReadWriteFormat(_ expression: KeyPath) -> Format { .init( read: ReadFormatBuilder.buildExpression(expression), @@ -32,6 +34,8 @@ extension FormatBuilder where Root: ReadWritable, Format == ReadWriteFormat(_ expression: C) -> Format where C.Value: ReadWritable { .init( read: ReadFormatBuilder.buildExpression(expression), @@ -39,6 +43,7 @@ extension FormatBuilder where Root: ReadWritable, Format == ReadWriteFormat(_ expression: S) -> Format where S.Element: ReadWritable & Equatable { .init(expression.map(buildExpression)) } diff --git a/Sources/DataKit/Builder/FormatBuilder+Write.swift b/Sources/DataKit/Builder/FormatBuilder+Write.swift index 27bbb1a..db82c01 100644 --- a/Sources/DataKit/Builder/FormatBuilder+Write.swift +++ b/Sources/DataKit/Builder/FormatBuilder+Write.swift @@ -1,30 +1,34 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 25.06.23. -// +// FormatBuilder+Write.swift import Foundation +/// Result-builder typealias for write formats — the type of `@WriteBuilder`. public typealias WriteFormatBuilder = FormatBuilder> extension FormatBuilder where Root: Writable, Format == WriteFormat { + /// Any ``WritableProperty`` (e.g. ``Property``, ``Convert``, ``Custom``, ``Scope``) + /// participates in a write builder. public static func buildExpression(_ expression: V) -> Format where V.Root == Format.Root { .init(write: expression.write) } + /// A ``Writable`` literal in a `@WriteBuilder` block is serialized verbatim — used for + /// emitting magic numbers, framing bytes, etc. public static func buildExpression(_ expression: V) -> Format { .init { container, _ in try expression.write(to: &container) } } + /// Bare key-path syntax (`\.field`) lifts to ``Property``. public static func buildExpression(_ expression: KeyPath) -> Format { buildExpression(Property(expression)) } + /// A bare ``Checksum`` value in a `@WriteBuilder` block computes the checksum over the + /// buffer accumulated so far and appends it (always in big-endian). To control the + /// input range, wrap the relevant section in a ``Scope``. public static func buildExpression(_ expression: C) -> Format where C.Value: Writable { buildExpression( WriteFormat { container, _ in @@ -35,6 +39,7 @@ extension FormatBuilder where Root: Writable, Format == WriteFormat { ) } + /// A sequence of ``Writable`` literals is emitted in order. public static func buildExpression(_ expression: S) -> Format where S.Element: Writable { .init(expression.map(buildExpression)) } diff --git a/Sources/DataKit/Builder/FormatBuilder.swift b/Sources/DataKit/Builder/FormatBuilder.swift index e6e6359..e1d3681 100644 --- a/Sources/DataKit/Builder/FormatBuilder.swift +++ b/Sources/DataKit/Builder/FormatBuilder.swift @@ -1,12 +1,14 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 25.06.23. -// +// FormatBuilder.swift import Foundation +/// The shared `@resultBuilder` machinery behind `@ReadBuilder`, `@WriteBuilder`, and the +/// unified `@FormatBuilder` typealiases on the three core protocols. +/// +/// Users rarely interact with `FormatBuilder` directly. The relevant typealiases are +/// ``Readable/ReadBuilder``, ``Writable/WriteBuilder``, and ``ReadWritable/FormatBuilder``. +/// Builder hooks include `buildArray` (`for ... { ... }` loops) and `buildOptional` (`if` +/// statements), so format declarations may use control flow. @resultBuilder public enum FormatBuilder where Format.Root == Root { diff --git a/Sources/DataKit/Conversions/Conversion+Cast.swift b/Sources/DataKit/Conversions/Conversion+Cast.swift index 823c606..ae086d6 100644 --- a/Sources/DataKit/Conversions/Conversion+Cast.swift +++ b/Sources/DataKit/Conversions/Conversion+Cast.swift @@ -1,14 +1,18 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 25.07.23. -// +// Conversion+Cast.swift import Foundation +// Lossy numeric conversion operators (`init(_:)` semantics). +// +// Use ``Conversion/cast(_:from:)-...`` when you accept potential loss of precision or range +// (e.g. `Double → Float`, `UInt64 → UInt32`). For lossless-only conversions, use +// ``Conversion/exactly(_:from:)-...`` (throws on overflow); for saturating, use +// ``Conversion/clamped(_:from:)-...``. + extension Conversion where Target: BinaryFloatingPoint { + /// Casts the current floating-point target to a different floating-point type using + /// the standard library's `init(_:)`. Lossy. public func cast( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -16,6 +20,7 @@ extension Conversion where Target: BinaryFloatingPoint { appending { NewTarget($0) } } + /// Casts the current floating-point target to an integer type using `init(_:)`. Truncates. public func cast( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -27,6 +32,7 @@ extension Conversion where Target: BinaryFloatingPoint { extension Conversion where Target: BinaryInteger { + /// Casts the current integer target to a floating-point type using `init(_:)`. public func cast( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -34,6 +40,7 @@ extension Conversion where Target: BinaryInteger { appending { NewTarget($0) } } + /// Casts the current integer target to a different integer type using `init(_:)`. public func cast( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -45,6 +52,7 @@ extension Conversion where Target: BinaryInteger { extension ReversibleConversion where Target: BinaryFloatingPoint { + /// Reversible cast between two floating-point types. public func cast( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -52,6 +60,7 @@ extension ReversibleConversion where Target: BinaryFloatingPoint { appending { $0.cast() } revert: { $0.cast() } } + /// Reversible cast between floating-point and integer. Each direction is lossy. public func cast( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -63,6 +72,7 @@ extension ReversibleConversion where Target: BinaryFloatingPoint { extension ReversibleConversion where Target: BinaryInteger { + /// Reversible cast between integer and floating-point. public func cast( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -70,6 +80,7 @@ extension ReversibleConversion where Target: BinaryInteger { appending { $0.cast() } revert: { $0.cast() } } + /// Reversible cast between two integer types. public func cast( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self diff --git a/Sources/DataKit/Conversions/Conversion+Clamped.swift b/Sources/DataKit/Conversions/Conversion+Clamped.swift index 55032d0..dc8b7ca 100644 --- a/Sources/DataKit/Conversions/Conversion+Clamped.swift +++ b/Sources/DataKit/Conversions/Conversion+Clamped.swift @@ -1,14 +1,13 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 16.07.23. -// +// Conversion+Clamped.swift import Foundation extension Conversion where Target: BinaryInteger { + /// Saturating integer conversion using the standard library's `init(clamping:)`. + /// + /// Values outside the target type's representable range are clamped to the nearest + /// representable value. Non-throwing. public func clamped( to target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -20,6 +19,8 @@ extension Conversion where Target: BinaryInteger { extension ReversibleConversion where Target: BinaryInteger { + /// Reversible saturating integer conversion. Note: the two directions may not round-trip + /// for values that were clamped — once clamped, the original value is lost. public func clamped( to target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self diff --git a/Sources/DataKit/Conversions/Conversion+Encoding.swift b/Sources/DataKit/Conversions/Conversion+Encoding.swift index 7c562fd..0a87d9a 100644 --- a/Sources/DataKit/Conversions/Conversion+Encoding.swift +++ b/Sources/DataKit/Conversions/Conversion+Encoding.swift @@ -1,14 +1,16 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 26.07.23. -// +// Conversion+Encoding.swift import Foundation extension Conversion where Target: StringProtocol { + /// Encodes the current string target into `Data` using the given string encoding. + /// + /// - Parameters: + /// - encoding: The string encoding to use (e.g. `.utf8`, `.ascii`). + /// - allowLossyConversion: When `true`, characters that can't be represented are + /// replaced rather than failing. + /// - Throws: ``ConversionError`` if the string cannot be encoded. public func encoded(_ encoding: String.Encoding, allowLossyConversion: Bool = false) -> Appended { appending { string in guard let data = string.data(using: encoding, allowLossyConversion: allowLossyConversion) else { @@ -22,6 +24,9 @@ extension Conversion where Target: StringProtocol { extension Conversion where Target: Sequence { + /// Decodes the current byte-sequence target into a `String` using the given encoding. + /// + /// - Throws: ``ConversionError`` if the bytes cannot be decoded as `encoding`. public func encoded(_ encoding: String.Encoding) -> Appended { appending { bytes in guard let string = String(bytes: bytes, encoding: encoding) else { @@ -35,6 +40,8 @@ extension Conversion where Target: Sequence { extension ReversibleConversion where Target == String { + /// Two-way string ↔ data encoding using the same `String.Encoding` in both directions. + /// `allowLossyConversion` applies only to the string-to-data direction. public func encoded(_ encoding: String.Encoding, allowLossyConversion: Bool = false) -> Appended { appending { $0.encoded(encoding, allowLossyConversion: allowLossyConversion) diff --git a/Sources/DataKit/Conversions/Conversion+Exactly.swift b/Sources/DataKit/Conversions/Conversion+Exactly.swift index 45732aa..5219607 100644 --- a/Sources/DataKit/Conversions/Conversion+Exactly.swift +++ b/Sources/DataKit/Conversions/Conversion+Exactly.swift @@ -1,14 +1,17 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 16.07.23. -// +// Conversion+Exactly.swift import Foundation +// Throwing numeric conversion operators (`init(exactly:)` semantics). +// +// Use ``Conversion/exactly(_:from:)-...`` when you want overflow / non-representability to +// surface as an error rather than being silently truncated (``Conversion/cast(_:from:)-...``) +// or clamped (``Conversion/clamped(_:from:)-...``). + extension Conversion where Target: BinaryFloatingPoint { + /// Lossless floating-point → floating-point conversion. Throws ``ConversionError`` if + /// the value cannot be represented exactly in `NewTarget`. public func exactly( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -22,6 +25,8 @@ extension Conversion where Target: BinaryFloatingPoint { } } + /// Lossless floating-point → integer conversion. Throws ``ConversionError`` if the + /// value is non-integral or out of range. public func exactly( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -39,6 +44,8 @@ extension Conversion where Target: BinaryFloatingPoint { extension Conversion where Target: BinaryInteger { + /// Lossless integer → floating-point conversion. Throws ``ConversionError`` if the + /// value cannot be represented exactly in `NewTarget`. public func exactly( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -52,6 +59,8 @@ extension Conversion where Target: BinaryInteger { } } + /// Lossless integer → integer conversion. Throws ``ConversionError`` if the value + /// would overflow `NewTarget`. public func exactly( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -69,6 +78,7 @@ extension Conversion where Target: BinaryInteger { extension ReversibleConversion where Target: BinaryFloatingPoint { + /// Reversible lossless floating-point ↔ floating-point conversion. public func exactly( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -80,6 +90,7 @@ extension ReversibleConversion where Target: BinaryFloatingPoint { } } + /// Reversible lossless floating-point ↔ integer conversion. public func exactly( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -95,6 +106,7 @@ extension ReversibleConversion where Target: BinaryFloatingPoint { extension ReversibleConversion where Target: BinaryInteger { + /// Reversible lossless integer ↔ floating-point conversion. public func exactly( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self @@ -106,6 +118,7 @@ extension ReversibleConversion where Target: BinaryInteger { } } + /// Reversible lossless integer ↔ integer conversion. public func exactly( _ target: NewTarget.Type = NewTarget.self, from source: Target.Type = Target.self diff --git a/Sources/DataKit/Conversions/Conversion+KeyPath.swift b/Sources/DataKit/Conversions/Conversion+KeyPath.swift index 8ae7beb..681fea2 100644 --- a/Sources/DataKit/Conversions/Conversion+KeyPath.swift +++ b/Sources/DataKit/Conversions/Conversion+KeyPath.swift @@ -1,14 +1,11 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 16.07.23. -// +// Conversion+KeyPath.swift import Foundation extension Conversion { + /// Projects the current target through a key path. Useful when you want to operate on + /// a component of a composite type (e.g. `.at(\.count)` after a sequence conversion). public func at( _ keyPath: KeyPath ) -> Appended { @@ -19,6 +16,9 @@ extension Conversion { extension ReversibleConversion { + /// Projects through paired forward/backward key paths so the conversion stays + /// invertible. The two key paths must address types that mutually project to each + /// other; mismatched paths are a silent footgun. public func at( _ forward: KeyPath, _ backward: KeyPath @@ -30,6 +30,9 @@ extension ReversibleConversion { } } + /// Projects through a key path that has the same source and target type. Use the + /// `forward`/`backward` flags to apply the projection in only one direction (useful + /// for asymmetric formats where one side normalizes and the other does not). public func at( symmetric keyPath: KeyPath, forward: Bool = true, diff --git a/Sources/DataKit/Conversions/Conversion+Map.swift b/Sources/DataKit/Conversions/Conversion+Map.swift index 2aeb2a2..c86464b 100644 --- a/Sources/DataKit/Conversions/Conversion+Map.swift +++ b/Sources/DataKit/Conversions/Conversion+Map.swift @@ -1,12 +1,9 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 26.07.23. -// +// Conversion+Map.swift extension Conversion where Target: Sequence { + /// Lifts a per-element `Conversion` to a sequence conversion that produces a + /// `RangeReplaceableCollection`. public func map( to target: NewTarget.Type = NewTarget.self, _ make: Conversion.Make @@ -25,6 +22,8 @@ extension Conversion where Target: Sequence { extension ReversibleConversion where Target: RangeReplaceableCollection { + /// Two-way variant of ``Conversion/map(to:_:)`` — the per-element conversion must itself + /// be reversible. public func map( to target: NewTarget.Type = NewTarget.self, _ make: ReversibleConversion.Make diff --git a/Sources/DataKit/Conversions/Conversion+Measurement.swift b/Sources/DataKit/Conversions/Conversion+Measurement.swift index fb34ad6..9ebf4f6 100644 --- a/Sources/DataKit/Conversions/Conversion+Measurement.swift +++ b/Sources/DataKit/Conversions/Conversion+Measurement.swift @@ -1,18 +1,15 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 25.07.23. -// +// Conversion+Measurement.swift extension Conversion { + /// Projects a `Measurement` target onto the scalar value in `unit`. public func converted( to unit: UnitType ) -> Appended where Target == Measurement { appending { $0.converted(to: unit).value } } + /// Lifts a `Double` target into a `Measurement` with the given unit. public func converted( to unit: UnitType ) -> Appended> where Target == Double { @@ -23,6 +20,7 @@ extension Conversion { extension ReversibleConversion { + /// Reversible projection between `Measurement` and `Double`. public func converted( to unit: UnitType ) -> Appended where Target == Measurement { @@ -33,6 +31,7 @@ extension ReversibleConversion { } } + /// Reversible projection between `Double` and `Measurement`. public func converted( to unit: UnitType ) -> Appended> where Target == Double { diff --git a/Sources/DataKit/Conversions/Conversion+PrefixCount.swift b/Sources/DataKit/Conversions/Conversion+PrefixCount.swift index caa3be1..8f25ec6 100644 --- a/Sources/DataKit/Conversions/Conversion+PrefixCount.swift +++ b/Sources/DataKit/Conversions/Conversion+PrefixCount.swift @@ -1,14 +1,12 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 16.07.23. -// +// Conversion+PrefixCount.swift import Foundation extension Conversion where Target: Sequence { + /// Wraps the sequence target into a ``PrefixCountArray`` so it can be serialized as a + /// length-prefixed list. The on-wire layout is: one `Count` integer followed by the + /// elements. public func prefixCount( _ type: Count.Type ) -> Appended> { @@ -19,6 +17,7 @@ extension Conversion where Target: Sequence { extension Conversion { + /// Unwraps a ``PrefixCountArray`` back into the underlying collection. public func prefixCount( _ type: Count.Type ) -> Appended where Target == PrefixCountArray { @@ -29,6 +28,7 @@ extension Conversion { extension ReversibleConversion { + /// Reversible wrap/unwrap of a `RangeReplaceableCollection` into a ``PrefixCountArray``. public func prefixCount( _ type: Count.Type ) -> Appended> where Target: RangeReplaceableCollection { @@ -41,10 +41,17 @@ extension ReversibleConversion { } +/// A length-prefixed array. +/// +/// On the wire: one `Count` integer (in the current environment endianness) followed by +/// exactly that many `Element`s. The count is computed from `values.count` via +/// `Int(exactly:)` semantics on write — values whose count cannot be represented in `Count` +/// throw ``ConversionError``. public struct PrefixCountArray { // MARK: Stored Properties + /// The underlying elements. public let values: [Element] // MARK: Initialization @@ -98,3 +105,5 @@ extension PrefixCountArray: ReadWritable where Count: ReadWritable, Element: Rea } } + +extension PrefixCountArray: Sendable where Count: Sendable, Element: Sendable {} diff --git a/Sources/DataKit/Conversions/Conversion+Reversible.swift b/Sources/DataKit/Conversions/Conversion+Reversible.swift index 4a63741..16cb8eb 100644 --- a/Sources/DataKit/Conversions/Conversion+Reversible.swift +++ b/Sources/DataKit/Conversions/Conversion+Reversible.swift @@ -1,12 +1,17 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 28.07.23. -// +// Conversion+Reversible.swift import Foundation +/// A two-way value transform used inside ``Convert`` for ``ReadWritable`` roots. +/// +/// `ReversibleConversion` bundles a forward (`convert`) and reverse (`revert`) closure that +/// are expected to be inverses. Most ``Conversion`` operators have a reversible counterpart; +/// the operator chain inside ``make(_:)`` keeps both directions in sync. +/// +/// The two `convert(_:)` overloads are disambiguated by their argument label vs. parameter +/// type only — calling `convert(value)` with a `Source` runs the forward direction, calling +/// it with a `Target` runs the reverse. When in doubt, use the explicit +/// ``conversion`` / ``reversion`` projections. public struct ReversibleConversion { // MARK: Stored Properties @@ -26,10 +31,12 @@ public struct ReversibleConversion { // MARK: Methods + /// Runs the forward direction: `Source → Target`. public func convert(_ source: Source) throws -> Target { try _convert(source) } + /// Runs the reverse direction: `Target → Source`. public func convert(_ target: Target) throws -> Source { try _revert(target) } @@ -40,10 +47,12 @@ extension ReversibleConversion { // MARK: Nested Types + /// Builder-closure signature consumed by ``make(_:)``. public typealias Make = (ReversibleConversion) -> ReversibleConversion // MARK: Static Functions + /// Builds a `ReversibleConversion` by chaining operators off the identity reversible. public static func make(_ make: Make) -> Self { make(.init { $0 } revert: { $0 }) } @@ -58,6 +67,9 @@ extension ReversibleConversion { // MARK: Methods + /// Composes this reversible with paired forward/reverse closures. + /// + /// The two closures must be inverses for round-trip correctness. public func appending( convert: @escaping (Target) throws -> NewTarget, revert: @escaping (NewTarget) throws -> Target @@ -69,6 +81,8 @@ extension ReversibleConversion { } } + /// Composes this reversible with a pair of `Conversion` builders for the forward and + /// reverse directions. public func appending( convert: Conversion.Make, revert: Conversion.Make @@ -85,14 +99,17 @@ extension ReversibleConversion { // MARK: Computed Properties + /// Projects onto the forward `Conversion`. public var conversion: Conversion { .init(_convert) } + /// Projects onto the reverse `Conversion`. public var reversion: Conversion { .init(_revert) } + /// Swaps the forward and reverse directions. public var inverted: ReversibleConversion { .init(convert: _revert, revert: _convert) } diff --git a/Sources/DataKit/Conversions/Conversion+VariableCount.swift b/Sources/DataKit/Conversions/Conversion+VariableCount.swift index d34a0c6..6c08241 100644 --- a/Sources/DataKit/Conversions/Conversion+VariableCount.swift +++ b/Sources/DataKit/Conversions/Conversion+VariableCount.swift @@ -1,14 +1,12 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 16.07.23. -// +// Conversion+VariableCount.swift import Foundation extension Conversion where Target: Sequence { + /// Wraps the sequence target into a ``DynamicCountArray`` — a variable-length list whose + /// boundary is determined by either ``EnvironmentValues/suffix`` or the surrounding + /// container's bounds, rather than by an explicit length prefix. public var dynamicCount: Appended> { appending { .init($0) } } @@ -17,6 +15,7 @@ extension Conversion where Target: Sequence { extension Conversion { + /// Unwraps a ``DynamicCountArray`` back into a `RangeReplaceableCollection`. public func dynamicCount( _ target: NewTarget.Type = NewTarget.self ) -> Appended where Target == DynamicCountArray { @@ -27,6 +26,7 @@ extension Conversion { extension ReversibleConversion where Target: RangeReplaceableCollection { + /// Reversible wrap/unwrap of a `RangeReplaceableCollection` into a ``DynamicCountArray``. public var dynamicCount: Appended> { appending { $0.dynamicCount @@ -37,10 +37,23 @@ extension ReversibleConversion where Target: RangeReplaceableCollection { } +/// A variable-length array whose extent is determined at runtime rather than by a length +/// prefix. +/// +/// Reading behavior depends on the active ``EnvironmentValues/suffix`` value: +/// +/// - If `suffix` is `nil`, elements are read until the container is exhausted. +/// - If `suffix.isRequired == true`, elements are read until the upcoming bytes match the +/// terminator, which is then consumed. If the stream ends before the terminator is found, +/// the inner element read raises ``ReadContainer/LengthExceededError``. +/// - If `suffix.isRequired == false`, reading also stops at EOF. +/// +/// On write, the terminator (if any) is appended after the elements. public struct DynamicCountArray { // MARK: Stored Properties + /// The underlying elements. public let values: [Element] // MARK: Initialization @@ -102,3 +115,5 @@ extension DynamicCountArray: ReadWritable where Element: ReadWritable { } } + +extension DynamicCountArray: Sendable where Element: Sendable {} diff --git a/Sources/DataKit/Conversions/Conversion.swift b/Sources/DataKit/Conversions/Conversion.swift index c00f788..d3b12ad 100644 --- a/Sources/DataKit/Conversions/Conversion.swift +++ b/Sources/DataKit/Conversions/Conversion.swift @@ -1,12 +1,26 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 16.07.23. -// +// Conversion.swift import Foundation +/// A reusable, composable one-way value transform used by ``Convert`` and +/// ``Property/conversion(_:)``. +/// +/// `Conversion` is the composable "I want this in-memory value to ride the wire as a +/// different type" primitive. You rarely create a `Conversion` directly — instead you +/// describe one with the ``make(_:)`` DSL: +/// +/// ```swift +/// Convert(\.fileSize) { $0.exactly(UInt32.self) } +/// ``` +/// +/// The closure receives an identity ``Conversion`` (from `Source` to itself) and returns a +/// `Conversion` from `Source` to whatever type the wire expects, built up by chaining +/// methods like ``cast(_:from:)``, ``exactly(_:from:)``, ``clamped(_:from:)``, +/// ``encoded(_:allowLossyConversion:)``, ``prefixCount(_:)``, ``dynamicCount``, and +/// ``converted(to:)``. +/// +/// For ``ReadWritable`` round-trips, use ``ReversibleConversion`` instead — it bundles a +/// pair of inverse transforms in one value. public struct Conversion { // MARK: Stored Properties @@ -21,6 +35,10 @@ public struct Conversion { // MARK: Methods + /// Applies the conversion to `source`. + /// + /// - Throws: Any error raised by the underlying transform (typically + /// ``ConversionError`` for value-domain failures). public func convert(_ source: Source) throws -> Target { try _convert(source) } @@ -29,8 +47,11 @@ public struct Conversion { extension Conversion { + /// The builder-closure signature consumed by ``make(_:)``: receives an identity + /// conversion and returns the composed result. public typealias Make = (Conversion) -> Conversion + /// Builds a `Conversion` by chaining operators off an identity conversion. public static func make(_ make: Make) -> Self { make(.init { $0 }) } @@ -39,14 +60,17 @@ extension Conversion { extension Conversion { + /// The type produced by ``appending(_:)-fdyu``. public typealias Appended = Conversion + /// Composes this conversion with a closure that transforms the target value further. public func appending( _ transform: @escaping (Target) throws -> NewTarget ) -> Appended { .init { try transform(convert($0)) } } + /// Composes this conversion with another `Conversion`. public func appending( _ conversion: Conversion ) -> Appended { diff --git a/Sources/DataKit/DataKit.docc/DataKit.md b/Sources/DataKit/DataKit.docc/DataKit.md new file mode 100644 index 0000000..4cb92f7 --- /dev/null +++ b/Sources/DataKit/DataKit.docc/DataKit.md @@ -0,0 +1,140 @@ +# ``DataKit`` + +A declarative DSL for reading and writing binary-formatted data in Swift. + +## Overview + +DataKit lets you describe the byte layout of a value once and use that single declaration +to encode and decode it. It is built on Swift's result-builder DSL — the style is +deliberately reminiscent of SwiftUI, but where SwiftUI builds views from declarations, +DataKit builds binary `Data`. + +A minimal example: + +```swift +struct Header: ReadWritable { + var magic: UInt16 + var count: UInt8 + + init(from context: ReadContext
) throws { + magic = try context.read(for: \.magic) + count = try context.read(for: \.count) + } + + static var format: Format { + \.magic + \.count + } +} + +let header = try Header(data) // decode +let bytes = try header.write() // encode +``` + +### The two-phase read model + +Decoding a ``Readable`` runs in two phases: + +1. The **format walk** parses each declared field from the input bytes and stores it in a + ``ReadContext``, keyed by `KeyPath`. +2. The **initializer** ``Readable/init(from:)-...`` retrieves those values back out of the + context by the *same* key paths and assembles `self`. + +The key paths used in the format declaration and in `init(from:)` must match exactly. +Mixing them up is not caught at compile time; instead ``ReadContext`` throws +``ReadContext/ValueDoesNotExistError`` at runtime. + +Writes do not go through a context — the format walk reads values directly off `self` via +the same key paths. + +### The environment + +Like SwiftUI, DataKit propagates ambient state through an ``EnvironmentValues`` struct. +Built-in keys: + +- ``EnvironmentValues/endianness`` (`nil` = host-native; explicit `.big` / `.little` is + almost always what wire protocols want). +- ``EnvironmentValues/suffix`` (the terminator for variable-length sequences). +- ``EnvironmentValues/skipChecksumVerification`` (read but do not validate checksum bytes). + +Scoped overrides are written with `.endianness(.big)`, `.suffix(0 as UInt8)`, etc. — the +previous value is restored once the wrapped subtree finishes. + +### Scopes, checksums, and the round-trip invariant + +A ``Scope`` carves out a sub-range of the surrounding container. The most common pairing +is `Scope { ... }` enclosing the payload, followed by a bare ``Checksum`` expression that +covers exactly the scoped bytes. `endInset:` on a read-side scope reserves trailing bytes +(e.g. the checksum's own footprint) so they are not part of the verified range. + +A `ReadWritable` conformance should always round-trip: `try T(t.write()) == t`. The +helpers in `Tests/DataKitTests/Extensions.swift` exercise this invariant for every +existing model — when adding new fields or new primitives, add a round-trip test of your +own. + +## Topics + +### Core Protocols + +- ``Readable`` +- ``Writable`` +- ``ReadWritable`` + +### Read and Write Containers + +- ``ReadContext`` +- ``ReadContainer`` +- ``WriteContainer`` + +### Format Primitives + +- ``Property`` +- ``Convert`` +- ``Custom`` +- ``Using`` +- ``Scope`` +- ``Environment`` +- ``OnRead`` +- ``OnWrite`` +- ``EnvironmentProperty`` +- ``ChecksumProperty`` + +### Format Types + +- ``FormatProperty`` +- ``FormatType`` +- ``ReadFormat`` +- ``WriteFormat`` +- ``ReadWriteFormat`` +- ``ReadableProperty`` +- ``WritableProperty`` + +### Result Builders + +- ``FormatBuilder`` +- ``DataBuilder`` + +### Environment + +- ``EnvironmentValues`` +- ``EnvironmentKey`` +- ``Endianness`` +- ``Suffix`` + +### Conversions + +- ``Conversion`` +- ``ReversibleConversion`` +- ``PrefixCountArray`` +- ``DynamicCountArray`` + +### Built-in Conformances + +Integers (`Int`/`Int8`…`Int64`/`UInt`/`UInt8`…`UInt64`), floating-point (`Float16` on +arm64, `Float32`, `Float64`), `RawRepresentable` (where `RawValue` is `ReadWritable`), +and `Optional` (where `Wrapped` is `ReadWritable`) all conform automatically. + +### Errors + +- ``ConversionError`` +- ``UnexpectedValueError`` diff --git a/Sources/DataKit/Environment/Environment+Endianness.swift b/Sources/DataKit/Environment/Environment+Endianness.swift index cfa6148..895e3d0 100644 --- a/Sources/DataKit/Environment/Environment+Endianness.swift +++ b/Sources/DataKit/Environment/Environment+Endianness.swift @@ -1,14 +1,14 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 24.06.23. -// +// Environment+Endianness.swift import Foundation -public enum Endianness: Equatable { +/// The byte order used when (de)serializing fixed-width numeric types. +public enum Endianness: Equatable, Sendable { + + /// Least-significant byte first. case little + + /// Most-significant byte first. case big } @@ -17,6 +17,15 @@ private enum EndiannessKey: EnvironmentKey { } extension EnvironmentValues { + + /// The byte order used for integer and floating-point fields. + /// + /// Defaults to `nil`, which means **host-native** byte order. Most wire protocols call + /// for a specific byte order — set this explicitly (typically `.big` or `.little`) at + /// the top of your format, or via the ``DataKit/FormatProperty/endianness(_:)`` modifier, + /// to avoid surprises when running on hardware with different native order. + /// + /// Checksum bytes are always written in big-endian and are unaffected by this setting. public var endianness: Endianness? { get { self[EndiannessKey.self] } set { self[EndiannessKey.self] = newValue } @@ -24,6 +33,10 @@ extension EnvironmentValues { } extension FormatProperty { + + /// Sets the endianness for the wrapped format subtree. + /// + /// - Parameter value: The byte order, or `nil` to reset to host-native within the subtree. public func endianness(_ value: Endianness?) -> EnvironmentProperty { environment(\.endianness, value) } diff --git a/Sources/DataKit/Environment/Environment+Suffix.swift b/Sources/DataKit/Environment/Environment+Suffix.swift index 98b8c69..00317b5 100644 --- a/Sources/DataKit/Environment/Environment+Suffix.swift +++ b/Sources/DataKit/Environment/Environment+Suffix.swift @@ -1,14 +1,19 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 16.07.23. -// +// Environment+Suffix.swift import Foundation -public struct Suffix { +/// A terminator marker for ``DynamicCountArray`` and similar variable-length sequences. +/// +/// `isRequired == true` means the terminator must be encountered for reads to succeed; if +/// the underlying stream ends before the terminator is seen, the inner element read will +/// throw ``ReadContainer/LengthExceededError``. `isRequired == false` means the read may +/// also stop cleanly at end-of-buffer. +public struct Suffix: Sendable { + + /// The byte sequence that terminates the value (e.g. `Data([0x00])` for a C-string). public let data: Data + + /// Whether reading must encounter the terminator before EOF. public let isRequired: Bool } @@ -18,6 +23,13 @@ private struct SuffixKey: EnvironmentKey { } extension EnvironmentValues { + + /// The active terminator for variable-length sequences inside the surrounding format. + /// + /// When `nil` (the default), variable-count reads consume until the end of the current + /// container. When set, reads stop after consuming the terminator (or, if + /// ``Suffix/isRequired`` is `false`, at EOF). On write, the terminator is appended + /// after the elements. public var suffix: Suffix? { get { self[SuffixKey.self] } set { self[SuffixKey.self] = newValue } @@ -26,10 +38,13 @@ extension EnvironmentValues { extension FormatProperty { + /// Sets the terminator for the wrapped subtree using raw bytes. public func suffix(_ data: Data?, isRequired: Bool = true) -> EnvironmentProperty { environment(\.suffix, data.map { .init(data: $0, isRequired: isRequired) }) } + /// Sets the terminator for the wrapped subtree by serializing a ``Writable`` value with + /// the current environment, then using its bytes as the terminator. public func suffix(_ value: V, isRequired: Bool = true) -> EnvironmentProperty { transformEnvironment { environment in let data = try value.write(with: environment) diff --git a/Sources/DataKit/Environment/EnvironmentValues.swift b/Sources/DataKit/Environment/EnvironmentValues.swift index 615e137..3b3ea51 100644 --- a/Sources/DataKit/Environment/EnvironmentValues.swift +++ b/Sources/DataKit/Environment/EnvironmentValues.swift @@ -1,18 +1,43 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 24.06.23. -// +// EnvironmentValues.swift import Foundation +/// A key for a custom value in ``EnvironmentValues``. +/// +/// Following the SwiftUI pattern: declare a private enum that conforms to `EnvironmentKey` +/// with a `defaultValue`, then extend ``EnvironmentValues`` with a property that uses the +/// key's subscript. The compiler-synthesized storage in `EnvironmentValues` is keyed by the +/// metatype identity of the conforming key. +/// +/// ```swift +/// private enum MyKey: EnvironmentKey { +/// static var defaultValue: Int { 0 } +/// } +/// +/// extension EnvironmentValues { +/// public var myValue: Int { +/// get { self[MyKey.self] } +/// set { self[MyKey.self] = newValue } +/// } +/// } +/// ``` public protocol EnvironmentKey { associatedtype Value + /// The value returned when no entry has been written for this key. static var defaultValue: Value { get } } +/// A SwiftUI-style ambient context propagated through the format walk. +/// +/// `EnvironmentValues` is carried by both ``ReadContainer`` and ``WriteContainer`` and reads +/// supply ambient information that the format walk needs but does not want to thread +/// through every call (endianness, the suffix terminator for dynamic-count sequences, +/// whether to skip checksum verification, etc.). +/// +/// Built-in keys: ``endianness`` (default `nil` = host-native), ``suffix`` (default `nil`), +/// ``skipChecksumVerification`` (default `false`). Add custom keys by declaring an +/// ``EnvironmentKey`` and extending `EnvironmentValues`. public struct EnvironmentValues { // MARK: Stored Properties @@ -21,10 +46,12 @@ public struct EnvironmentValues { // MARK: Initialization + /// Creates an empty environment with all keys at their default values. public init() {} // MARK: Methods + /// Reads or writes the value associated with `key`. public subscript(_ key: Key.Type) -> Key.Value { get { values[ObjectIdentifier(Key.self), default: Key.defaultValue] as! Key.Value } set { values[ObjectIdentifier(Key.self)] = newValue } diff --git a/Sources/DataKit/Environment/SkipChecksumVerification.swift b/Sources/DataKit/Environment/SkipChecksumVerification.swift index 7ded66f..bd7e3f1 100644 --- a/Sources/DataKit/Environment/SkipChecksumVerification.swift +++ b/Sources/DataKit/Environment/SkipChecksumVerification.swift @@ -1,9 +1,4 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 15.07.23. -// +// SkipChecksumVerification.swift import Foundation @@ -12,6 +7,13 @@ private enum SkipChecksumVerificationKey: EnvironmentKey { } extension EnvironmentValues { + + /// When `true`, ``ChecksumProperty`` reads the checksum bytes without comparing them + /// against the computed checksum. + /// + /// Useful for parsing corrupted or partial captures where you want the value (typically + /// exposed via the optional `keyPath` argument on ``ChecksumProperty``) but cannot + /// require the bytes to match. The default is `false`. public var skipChecksumVerification: Bool { get { self[SkipChecksumVerificationKey.self] } set { self[SkipChecksumVerificationKey.self] = newValue } @@ -19,6 +21,8 @@ extension EnvironmentValues { } extension FormatProperty { + + /// Disables (or re-enables) checksum verification for the wrapped subtree. public func skipChecksumVerification(_ value: Bool = true) -> EnvironmentProperty { environment(\.skipChecksumVerification, value) } diff --git a/Sources/DataKit/Error.swift b/Sources/DataKit/Error.swift index 9b17fcc..073d0b2 100644 --- a/Sources/DataKit/Error.swift +++ b/Sources/DataKit/Error.swift @@ -1,20 +1,32 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 15.07.23. -// +// Error.swift import Foundation -public struct ConversionError: Error { +/// Thrown by a `Conversion` or `ReversibleConversion` when the input value cannot be represented in the target type. +/// +/// The stored values are intended for debugging — they are type-erased to `Any` so the error +/// type itself can sit at the boundary between many different `Source`/`Target` pairs without +/// being made generic. To inspect them programmatically, cast `source` to the expected +/// source type and compare `targetType` against the expected target metatype. +public struct ConversionError: Error, @unchecked Sendable { + + /// The value that could not be converted. public let source: Any + + /// The target type the conversion was attempting to produce. public let targetType: Any.Type } -public struct CannotWriteNilError: Error {} +/// Thrown by a `ReadBuilder` when an expected literal value does not match the actual bytes read. +/// +/// Bare literal expressions inside a `ReadBuilder` (for example, `UInt8(0x02)` as a frame +/// prefix) parse the corresponding bytes and assert equality with the literal. A mismatch +/// throws this error. +public struct UnexpectedValueError: Error, @unchecked Sendable { -public struct UnexpectedValueError: Error { + /// The value declared in the format declaration. public let expectedValue: Any + + /// The value actually decoded from the input data. public let actualValue: Any } diff --git a/Sources/DataKit/Exports.swift b/Sources/DataKit/Exports.swift index 8d89f56..f077274 100644 --- a/Sources/DataKit/Exports.swift +++ b/Sources/DataKit/Exports.swift @@ -1,9 +1,4 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 25.07.23. -// +// Exports.swift @_exported import Foundation @_exported import CRC diff --git a/Sources/DataKit/Property/Checksum.swift b/Sources/DataKit/Property/Checksum.swift index 2f6d7bf..35900dd 100644 --- a/Sources/DataKit/Property/Checksum.swift +++ b/Sources/DataKit/Property/Checksum.swift @@ -1,12 +1,28 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 31.07.23. -// +// Checksum.swift import Foundation +/// A format property that reads/writes a checksum value covering the bytes accumulated so far +/// in the current container. +/// +/// On decode, `ChecksumProperty` reads `ChecksumType.Value` bytes from the input, computes the +/// expected value over ``ReadContainer/consumedData``, and throws on mismatch — unless +/// ``EnvironmentValues/skipChecksumVerification`` is `true`, in which case the value is read +/// without comparison. The checksum bytes are always read and written in big-endian byte order, +/// regardless of the surrounding environment endianness. +/// +/// On encode, the value is computed over the buffer accumulated so far and appended, unless +/// the optional `keyPath` argument carries a non-nil value, in which case that value is used +/// verbatim. +/// +/// To control which bytes the checksum covers, wrap the relevant section in a ``Scope``: +/// +/// ```swift +/// Scope(endInset: 4) { // reserve 4 trailing bytes for the CRC +/// \.payload +/// } +/// CRC32.default // bare checksum expression covers the scope +/// ``` public struct ChecksumProperty: FormatProperty { // MARK: Nested Types @@ -20,6 +36,12 @@ public struct ChecksumProperty: Form // MARK: Initialization + /// Reads and verifies a checksum, optionally exposing the decoded value at `keyPath`. + /// + /// - Parameters: + /// - checksum: The checksum algorithm. + /// - keyPath: Optional path that, when provided, stores the decoded checksum value into + /// the `ReadContext`. When `nil`, the value is verified and discarded. public init( _ checksum: ChecksumType, at keyPath: KeyPath? = nil @@ -28,7 +50,9 @@ public struct ChecksumProperty: Form ReadFormat { container, context in let verificationData = container.consumedData let value = try Value(from: &container) - try checksum.verify(value, for: verificationData) + if !container.environment.skipChecksumVerification { + try checksum.verify(value, for: verificationData) + } if let keyPath { try context.write(value, for: keyPath) } @@ -37,6 +61,8 @@ public struct ChecksumProperty: Form ) } + /// Reads and verifies a checksum, optionally exposing the decoded value at an + /// optional-typed `keyPath`. public init( _ checksum: ChecksumType, at keyPath: KeyPath? = nil @@ -45,7 +71,9 @@ public struct ChecksumProperty: Form ReadFormat { container, context in let verificationData = container.consumedData let value = try Value(from: &container) - try checksum.verify(value, for: verificationData) + if !container.environment.skipChecksumVerification { + try checksum.verify(value, for: verificationData) + } if let keyPath { try context.write(value, for: keyPath) } @@ -54,6 +82,8 @@ public struct ChecksumProperty: Form ) } + /// Writes a checksum computed over the buffer so far, or — if `keyPath` is non-nil — + /// writes the value carried by `Root` directly. public init( _ checksum: ChecksumType, at keyPath: KeyPath? = nil @@ -68,6 +98,8 @@ public struct ChecksumProperty: Form ) } + /// Writes a checksum, sourcing the value from an optional-typed property on `Root`. + /// If the property is `nil`, the checksum is computed over the buffer instead. public init( _ checksum: ChecksumType, at keyPath: KeyPath? = nil @@ -82,6 +114,7 @@ public struct ChecksumProperty: Form ) } + /// Unified read/write of a checksum for a ``ReadWritable`` root. public init( _ checksum: ChecksumType, at keyPath: KeyPath? = nil @@ -91,7 +124,9 @@ public struct ChecksumProperty: Form read: .init { container, context in let verificationData = container.consumedData let value = try Value(from: &container) - try checksum.verify(value, for: verificationData) + if !container.environment.skipChecksumVerification { + try checksum.verify(value, for: verificationData) + } if let keyPath { try context.write(value, for: keyPath) } @@ -106,6 +141,7 @@ public struct ChecksumProperty: Form ) } + /// Unified read/write of a checksum for a ``ReadWritable`` root with an optional value path. public init( _ checksum: ChecksumType, at keyPath: KeyPath? = nil @@ -115,7 +151,9 @@ public struct ChecksumProperty: Form read: .init { container, context in let verificationData = container.consumedData let value = try Value(from: &container) - try checksum.verify(value, for: verificationData) + if !container.environment.skipChecksumVerification { + try checksum.verify(value, for: verificationData) + } if let keyPath { try context.write(value, for: keyPath) } diff --git a/Sources/DataKit/Property/Convert.swift b/Sources/DataKit/Property/Convert.swift index 525383e..829ac11 100644 --- a/Sources/DataKit/Property/Convert.swift +++ b/Sources/DataKit/Property/Convert.swift @@ -1,12 +1,20 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 27.07.23. -// +// Convert.swift import Foundation +/// Reads or writes a field using a different on-wire type than the in-memory type. +/// +/// `Convert` is the bridge between a model's natural Swift type and the bytes a protocol +/// actually carries. Three variants exist: +/// +/// - With a ``Conversion``/``ReversibleConversion`` builder: `Convert(\.field) { $0.exactly(UInt16.self) }`. +/// - With raw closures: `Convert(\.field, convert: { ... })` for one-direction conversions, +/// or `Convert(\.field, reading: { ... }, writing: { ... })` for the symmetric case. +/// - For ``ReadWritable`` roots, the reversible form is required so the conversion runs +/// in both directions. +/// +/// For the closure-based read initializer, the closure maps **wire type → in-memory type**. +/// For the closure-based write initializer, the closure maps **in-memory type → wire type**. public struct Convert: FormatProperty { // MARK: Nested Types @@ -19,6 +27,7 @@ public struct Convert: FormatProperty { // MARK: Initialization + /// Read-only conversion using a ``Conversion`` builder. public init( _ keyPath: KeyPath, conversion makeConversion: Conversion.Make @@ -29,6 +38,7 @@ public struct Convert: FormatProperty { ) } + /// Read-only conversion using a raw closure (wire type → in-memory type). public init( _ keyPath: KeyPath, convert: @escaping (ConvertedValue) throws -> Value @@ -39,6 +49,7 @@ public struct Convert: FormatProperty { } } + /// Write-only conversion using a ``Conversion`` builder. public init( _ keyPath: KeyPath, conversion makeConversion: Conversion.Make @@ -49,6 +60,7 @@ public struct Convert: FormatProperty { ) } + /// Write-only conversion using a raw closure (in-memory type → wire type). public init( _ keyPath: KeyPath, convert: @escaping (Value) throws -> ConvertedValue @@ -58,6 +70,7 @@ public struct Convert: FormatProperty { } } + /// Reversible conversion for a ``ReadWritable`` root, using a ``ReversibleConversion`` builder. public init( _ keyPath: KeyPath, conversion makeConversion: ReversibleConversion.Make @@ -66,6 +79,11 @@ public struct Convert: FormatProperty { self.init(keyPath, reading: conversion.convert, writing: conversion.convert) } + /// Reversible conversion for a ``ReadWritable`` root, using paired raw closures. + /// + /// - Parameters: + /// - reading: Maps wire type → in-memory type during decode. + /// - writing: Maps in-memory type → wire type during encode. public init( _ keyPath: KeyPath, reading: @escaping (ConvertedValue) throws -> Value, @@ -95,5 +113,3 @@ extension Convert: WritableProperty where Format: WritableProperty { try format.write(to: &container, using: root) } } - - diff --git a/Sources/DataKit/Property/Custom.swift b/Sources/DataKit/Property/Custom.swift index d83d0dd..075b877 100644 --- a/Sources/DataKit/Property/Custom.swift +++ b/Sources/DataKit/Property/Custom.swift @@ -1,12 +1,17 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 27.07.23. -// +// Custom.swift import Foundation +/// Drops down to raw `ReadContainer`/`WriteContainer` access for a single field. +/// +/// `Custom` is the escape hatch for fields that cannot be expressed with the existing +/// primitives or with a ``Convert`` + ``Conversion``. The `read` closure is not annotated +/// `throws` in its signature, but it is invoked inside a throwing context — call sites can +/// throw via `try` inside the closure body using `try!`/`try?` patterns or by lifting the +/// logic into a helper that throws. +/// +/// If the same custom read/write logic appears more than once in your codebase, lift it +/// into a reusable ``Conversion`` or ``ReversibleConversion`` instead. public struct Custom: FormatProperty { // MARK: Nested Types @@ -19,6 +24,7 @@ public struct Custom: FormatProperty { // MARK: Initialization + /// Read-only custom logic. public init( _ keyPath: KeyPath, read: @escaping (inout ReadContainer) -> Value @@ -28,6 +34,7 @@ public struct Custom: FormatProperty { } } + /// Write-only custom logic. public init( _ keyPath: KeyPath, write: @escaping (inout WriteContainer, Value) throws -> Void @@ -37,6 +44,7 @@ public struct Custom: FormatProperty { } } + /// Paired custom read and write for a ``ReadWritable`` root. public init( _ keyPath: KeyPath, read: @escaping (inout ReadContainer) -> Value, diff --git a/Sources/DataKit/Property/Environment.swift b/Sources/DataKit/Property/Environment.swift index bbf56a0..0d36329 100644 --- a/Sources/DataKit/Property/Environment.swift +++ b/Sources/DataKit/Property/Environment.swift @@ -1,12 +1,17 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 15.07.23. -// +// Environment.swift import Foundation +/// Reads a value out of ``EnvironmentValues`` at format-walk time and feeds it into a +/// sub-format builder. +/// +/// `Environment` is the format-level analogue of SwiftUI's `@Environment` property wrapper: +/// it lets a format adapt to ambient state (endianness, suffix terminator, etc.) without +/// the call site having to thread that state through every call. +/// +/// `Environment` is similar to ``Using`` but reads from the *environment* rather than from +/// the model. Use `Using` when the value depends on the parsed data; use `Environment` +/// when it depends on configuration applied by an outer modifier. public struct Environment: FormatProperty { // MARK: Nested Types @@ -20,6 +25,7 @@ public struct Environment: FormatProperty { // MARK: Initialization + /// Read-only variant. public init ( _ keyPath: KeyPath, @FormatBuilder format: @escaping (Value) throws -> Format @@ -28,6 +34,7 @@ public struct Environment: FormatProperty { self.format = format } + /// Write-only variant. public init ( _ keyPath: KeyPath, @FormatBuilder format: @escaping (Value) throws -> Format @@ -36,6 +43,7 @@ public struct Environment: FormatProperty { self.format = format } + /// Read+write variant for a ``ReadWritable`` root. public init ( _ keyPath: KeyPath, @FormatBuilder format: @escaping (Value) throws -> Format diff --git a/Sources/DataKit/Property/EnvironmentProperty.swift b/Sources/DataKit/Property/EnvironmentProperty.swift index ab76937..92f9916 100644 --- a/Sources/DataKit/Property/EnvironmentProperty.swift +++ b/Sources/DataKit/Property/EnvironmentProperty.swift @@ -1,14 +1,16 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 25.06.23. -// +// EnvironmentProperty.swift import Foundation extension FormatProperty { + /// Sets a single environment value for the duration of `self`'s read/write. + /// + /// The previous value is restored once the wrapped format completes. + /// + /// ```swift + /// \.bigEndianField.environment(\.endianness, .big) + /// ``` public func environment( _ keyPath: WritableKeyPath, _ value: Value @@ -16,6 +18,7 @@ extension FormatProperty { EnvironmentProperty(self) { $0[keyPath: keyPath] = value } } + /// Mutates a single environment value via a closure for the duration of `self`'s read/write. public func transformEnvironment( _ keyPath: WritableKeyPath, transform: @escaping (inout Value) throws -> Void @@ -23,6 +26,7 @@ extension FormatProperty { EnvironmentProperty(self) { try transform(&$0[keyPath: keyPath]) } } + /// Mutates the full environment via a closure for the duration of `self`'s read/write. public func transformEnvironment( transform: @escaping (inout EnvironmentValues) throws -> Void ) -> EnvironmentProperty { @@ -31,6 +35,13 @@ extension FormatProperty { } +/// Scopes a transient environment change to a single format subtree. +/// +/// Created indirectly via ``FormatProperty/environment(_:_:)``, +/// ``FormatProperty/transformEnvironment(_:transform:)``, or +/// ``FormatProperty/transformEnvironment(transform:)``. The `transform` closure runs before +/// the wrapped format and the previous environment is restored afterwards, giving SwiftUI- +/// style scoped propagation. public struct EnvironmentProperty: FormatProperty { // MARK: Nested Types diff --git a/Sources/DataKit/Property/KeyPath.swift b/Sources/DataKit/Property/KeyPath.swift index e91be7e..e9f90ca 100644 --- a/Sources/DataKit/Property/KeyPath.swift +++ b/Sources/DataKit/Property/KeyPath.swift @@ -1,12 +1,10 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 28.07.23. -// +// KeyPath.swift import Foundation +// Conformances that let a bare key path expression (e.g. `\.magic`) participate in format +// builders. These are equivalent to wrapping the key path in `Property(_:)`. + extension KeyPath: FormatProperty {} extension KeyPath: ReadableProperty where Root: Readable, Value: Readable { diff --git a/Sources/DataKit/Property/OnRead.swift b/Sources/DataKit/Property/OnRead.swift index db8b074..7493f0c 100644 --- a/Sources/DataKit/Property/OnRead.swift +++ b/Sources/DataKit/Property/OnRead.swift @@ -1,12 +1,13 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 14.07.23. -// +// OnRead.swift import Foundation +/// Lifts a read-only format into a ``ReadWritable`` context. The format runs on decode and +/// is a no-op on encode. +/// +/// Use this for fields you want to parse but do not need to write back out — e.g. asserted +/// constants whose bytes are produced separately, or padding that you want to skip without +/// emitting on write. public struct OnRead: ReadableProperty, WritableProperty { // MARK: Stored Properties diff --git a/Sources/DataKit/Property/OnWrite.swift b/Sources/DataKit/Property/OnWrite.swift index dc52bdc..1690e74 100644 --- a/Sources/DataKit/Property/OnWrite.swift +++ b/Sources/DataKit/Property/OnWrite.swift @@ -1,12 +1,12 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 14.07.23. -// +// OnWrite.swift import Foundation +/// Lifts a write-only format into a ``ReadWritable`` context. The format runs on encode and +/// is a no-op on decode. +/// +/// Use this for fields you want to emit but do not need to parse back out — e.g. a magic +/// number that the read side handles via another mechanism, or padding bytes. public struct OnWrite: ReadableProperty, WritableProperty { // MARK: Stored Properties diff --git a/Sources/DataKit/Property/Property+Conversion.swift b/Sources/DataKit/Property/Property+Conversion.swift index 080901f..9799f52 100644 --- a/Sources/DataKit/Property/Property+Conversion.swift +++ b/Sources/DataKit/Property/Property+Conversion.swift @@ -1,20 +1,17 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 26.07.23. -// +// Property+Conversion.swift import Foundation extension Property where Root: Readable { + /// Fluent equivalent of `Convert(\.kp, conversion: ...)` for reading. public func conversion( _ makeConversion: Conversion.Make ) -> Convert> { Convert(keyPath, conversion: makeConversion) } + /// Fluent equivalent of `Convert(\.kp, convert: ...)` for reading. public func converted( _ convert: @escaping (ConvertedValue) throws -> Value ) -> Convert> { @@ -25,12 +22,14 @@ extension Property where Root: Readable { extension Property where Root: Writable { + /// Fluent equivalent of `Convert(\.kp, conversion: ...)` for writing. public func conversion( _ makeConversion: Conversion.Make ) -> Convert> { Convert(keyPath, conversion: makeConversion) } + /// Fluent equivalent of `Convert(\.kp, convert: ...)` for writing. public func converted( _ convert: @escaping (Value) throws -> ConvertedValue ) -> Convert> { @@ -41,12 +40,14 @@ extension Property where Root: Writable { extension Property where Root: ReadWritable { + /// Fluent equivalent of `Convert(\.kp, conversion: ...)` for read+write. public func conversion( _ makeConversion: ReversibleConversion.Make ) -> Convert> { Convert(keyPath, conversion: makeConversion) } + /// Fluent equivalent of `Convert(\.kp, reading:writing:)`. public func converted( reading: @escaping (ConvertedValue) throws -> Value, writing: @escaping (Value) throws -> ConvertedValue diff --git a/Sources/DataKit/Property/Property+Custom.swift b/Sources/DataKit/Property/Property+Custom.swift index ee1ce23..467e2c9 100644 --- a/Sources/DataKit/Property/Property+Custom.swift +++ b/Sources/DataKit/Property/Property+Custom.swift @@ -1,14 +1,10 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 26.07.23. -// +// Property+Custom.swift import Foundation extension Property where Root: Readable { + /// Fluent equivalent of `Custom(\.kp, read: ...)`. public func read( _ read: @escaping (inout ReadContainer) -> Value ) -> Custom> { @@ -19,6 +15,7 @@ extension Property where Root: Readable { extension Property where Root: Writable { + /// Fluent equivalent of `Custom(\.kp, write: ...)`. public func write( _ write: @escaping (inout WriteContainer, Value) throws -> Void ) -> Custom> { @@ -29,6 +26,7 @@ extension Property where Root: Writable { extension Property where Root: ReadWritable { + /// Fluent equivalent of `Custom(\.kp, read:write:)`. public func read( _ read: @escaping (inout ReadContainer) -> Value, write: @escaping (inout WriteContainer, Value) throws -> Void diff --git a/Sources/DataKit/Property/Property.swift b/Sources/DataKit/Property/Property.swift index 6cecc7b..45adf17 100644 --- a/Sources/DataKit/Property/Property.swift +++ b/Sources/DataKit/Property/Property.swift @@ -1,12 +1,14 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 14.07.23. -// +// Property.swift import Foundation +/// Wraps a key path so it can appear in a format builder when the surrounding context +/// cannot infer the `Root` type from a bare `\.foo`. +/// +/// In most cases, a bare key path inside a format block (e.g. `\.magic`) is enough — the +/// conformance in `KeyPath.swift` adopts ``ReadableProperty`` and ``WritableProperty`` for you. +/// Reach for `Property` only when the compiler complains about the root type, or when you +/// need to chain fluent methods like ``conversion(_:)`` / ``converted(_:)``. public struct Property: FormatProperty { // MARK: Stored Properties @@ -15,6 +17,7 @@ public struct Property: FormatProperty { // MARK: Initialization + /// Wraps `keyPath` so it can be used inside a format builder. public init(_ keyPath: KeyPath) { self.keyPath = keyPath } diff --git a/Sources/DataKit/Property/Scope.swift b/Sources/DataKit/Property/Scope.swift index 9e9a36e..6baef74 100644 --- a/Sources/DataKit/Property/Scope.swift +++ b/Sources/DataKit/Property/Scope.swift @@ -1,12 +1,24 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 14.07.23. -// +// Scope.swift import Foundation +/// Restricts the data view of an inner format to a sub-range of the surrounding container. +/// +/// `Scope` is the building block for protocols where the boundaries of "the message" matter +/// independently from the boundaries of any one field. Two common uses: +/// +/// - **Bounded checksum coverage.** A ``ChecksumProperty`` inside a `Scope` covers exactly +/// the bytes consumed inside that scope, not the entire parent container. Use `endInset` +/// to reserve trailing bytes (typically the checksum's own bytes) so they are not part +/// of the verified range. +/// - **Excluding a prefix/suffix from a covered region.** A constant frame prefix can be +/// read outside the scope and therefore omitted from the checksum domain. +/// +/// On decode, the inner format runs against a sub-container that ends `endInset` bytes +/// before the parent's current end. The outer cursor is advanced by however many bytes +/// the inner format actually consumed. On encode, the inner format runs into a fresh +/// `WriteContainer` and the resulting bytes are appended to the outer container — there is +/// no write-side `endInset`, because reserving trailing bytes is meaningful only for reads. public struct Scope: FormatProperty { // MARK: Nested Types @@ -20,6 +32,11 @@ public struct Scope: FormatProperty { // MARK: Initialization + /// Creates a read-only scope. + /// + /// - Parameters: + /// - endInset: Number of trailing bytes to leave outside the scope (e.g. the size of + /// a checksum that follows the covered range). Defaults to `0`. public init( endInset: Int = 0, @FormatBuilder format: () -> Format @@ -28,6 +45,8 @@ public struct Scope: FormatProperty { self.format = format() } + /// Creates a write-only scope. The write-side has no `endInset` because trailing-byte + /// reservation is not meaningful when serializing. public init( @FormatBuilder format: () -> Format ) where Format == WriteFormat { @@ -35,6 +54,11 @@ public struct Scope: FormatProperty { self.format = format() } + /// Creates a read+write scope. + /// + /// - Parameters: + /// - endInset: Read-side: number of trailing bytes to leave outside the scope. + /// Ignored on write. public init( endInset: Int = 0, @FormatBuilder format: () -> Format diff --git a/Sources/DataKit/Property/Using.swift b/Sources/DataKit/Property/Using.swift index f951341..42456b1 100644 --- a/Sources/DataKit/Property/Using.swift +++ b/Sources/DataKit/Property/Using.swift @@ -1,12 +1,18 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 23.06.23. -// +// Using.swift import Foundation +/// Builds a sub-format that depends on a value already read into the context (or already +/// present on the root, during a write). +/// +/// `Using` is the workhorse for length-prefixed and feature-flagged formats: a count or flag +/// byte is parsed first, then `Using` branches on that value to decide what to read next. +/// Unlike ``Property``, `Using` does **not** itself read bytes — it consumes a value that an +/// earlier statement already produced. +/// +/// On read, `Using` retrieves the value from `ReadContext` via `keyPath` (which means a +/// matching `Property(\.kp)` / `\.kp` must precede it). On write, it reads the value +/// directly off `root` via the same key path. The builder closure is invoked once per pass. public struct Using: FormatProperty { // MARK: Nested Types @@ -20,6 +26,7 @@ public struct Using: FormatProperty { // MARK: Initialization + /// Read-only branching on a previously-read value. public init( _ keyPath: KeyPath, @FormatBuilder with format: @escaping (Value) throws -> Format @@ -28,6 +35,7 @@ public struct Using: FormatProperty { self.format = format } + /// Write-only branching on a value carried by `root`. public init( _ keyPath: KeyPath, @FormatBuilder with format: @escaping (Value) throws -> Format @@ -36,6 +44,7 @@ public struct Using: FormatProperty { self.format = format } + /// Read+write branching for a ``ReadWritable`` root. public init( _ keyPath: KeyPath, @FormatBuilder with format: @escaping (Value) throws -> Format diff --git a/Sources/DataKit/ReadWritable/Format.swift b/Sources/DataKit/ReadWritable/Format.swift index e8b6c74..dc66312 100644 --- a/Sources/DataKit/ReadWritable/Format.swift +++ b/Sources/DataKit/ReadWritable/Format.swift @@ -1,16 +1,20 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 15.07.23. -// +// Format.swift import Foundation +/// Any value that can appear inside a `FormatBuilder` result-builder context. +/// +/// You usually do not implement this protocol directly — built-in primitives such as +/// ``Property``, ``Convert``, ``Custom``, ``Using``, ``Scope``, and ``Environment`` already +/// conform. Conditional conformances to ``ReadableProperty`` and ``WritableProperty`` +/// determine whether the value can be used in a read, write, or read-write context. public protocol FormatProperty { associatedtype Root } +/// The umbrella protocol for the three concrete format types: ``ReadFormat``, ``WriteFormat``, +/// and ``ReadWriteFormat``. The `init(_:)` requirement lets the framework flatten an array +/// of formats into a single one — used by the result builder when combining statements. public protocol FormatType: FormatProperty { init(_ multiple: [Self]) } diff --git a/Sources/DataKit/ReadWritable/ReadWritable.swift b/Sources/DataKit/ReadWritable/ReadWritable.swift index bec53a3..02a59c7 100644 --- a/Sources/DataKit/ReadWritable/ReadWritable.swift +++ b/Sources/DataKit/ReadWritable/ReadWritable.swift @@ -1,14 +1,40 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 22.06.23. -// +// ReadWritable.swift import Foundation +/// A type that can be both decoded from and encoded to binary `Data` using a single +/// declarative format. +/// +/// `ReadWritable` is the recommended conformance when your type round-trips in both +/// directions. You declare the byte layout once via ``format``; the synthesized +/// ``Readable/readFormat`` and ``Writable/writeFormat`` are derived from it automatically. +/// +/// ```swift +/// struct Header: ReadWritable { +/// var magic: UInt16 +/// var count: UInt8 +/// +/// init(from context: ReadContext
) throws { +/// magic = try context.read(for: \.magic) +/// count = try context.read(for: \.count) +/// } +/// +/// static var format: Format { +/// \.magic +/// \.count +/// } +/// } +/// ``` +/// +/// The same key-path-matching requirement from ``Readable`` applies: every key path +/// declared in ``format`` must also be retrieved from `ReadContext` in `init(from:)`. public protocol ReadWritable: Readable, Writable { + /// The unified declarative description of how to read and write `Self`. + /// + /// Each statement runs once on decode and once on encode. Operations that only make + /// sense in one direction (asserting magic bytes, computing a checksum) are handled + /// symmetrically by the framework. @FormatBuilder static var format: Format { get throws } diff --git a/Sources/DataKit/ReadWritable/ReadWritableProperty.swift b/Sources/DataKit/ReadWritable/ReadWritableProperty.swift index fc6168b..54f9e86 100644 --- a/Sources/DataKit/ReadWritable/ReadWritableProperty.swift +++ b/Sources/DataKit/ReadWritable/ReadWritableProperty.swift @@ -1,12 +1,13 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 22.06.23. -// +// ReadWritableProperty.swift import Foundation +/// A combined read+write format produced by a `@FormatBuilder` block. +/// +/// `ReadWriteFormat` is what you return from `static var format` on a ``ReadWritable`` +/// type. Internally it carries a `ReadFormat` and a `WriteFormat` and delegates +/// to them; the framework synthesizes ``Readable/readFormat`` and ``Writable/writeFormat`` +/// by projecting onto these underlying formats. public struct ReadWriteFormat: FormatType, ReadableProperty, WritableProperty { // MARK: Stored Properties @@ -16,11 +17,14 @@ public struct ReadWriteFormat: FormatType, ReadableProperty, // MARK: Initialization + /// Pairs an existing read format with a write format. The two are expected to be + /// inverses — their bytes must round-trip identically. public init(read: ReadFormat, write: WriteFormat) { self.readFormat = read self.writeFormat = write } + /// Sequentially composes multiple `ReadWriteFormat`s into one. public init(_ multiple: [ReadWriteFormat]) { self.init( read: .init(multiple.map(\.readFormat)), @@ -39,3 +43,4 @@ public struct ReadWriteFormat: FormatType, ReadableProperty, } } + diff --git a/Sources/DataKit/Readable/ReadContainer.swift b/Sources/DataKit/Readable/ReadContainer.swift index 6cce2ea..e5e42f0 100644 --- a/Sources/DataKit/Readable/ReadContainer.swift +++ b/Sources/DataKit/Readable/ReadContainer.swift @@ -1,36 +1,61 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 15.07.23. -// +// ReadContainer.swift import Foundation +/// The mutable read cursor passed through the format walk. +/// +/// A `ReadContainer` carries the full source `data`, the current cursor `index`, and the +/// active `EnvironmentValues`. Format properties advance the cursor via ``consume(_:)`` +/// or by recursively running nested format readers. +/// +/// A nested `Scope` runs the inner format against a *sub-container* whose `data` covers +/// only the scoped sub-range. This is what makes `consumedData` and `remainingData` +/// correctly reflect just the scoped region — important for checksum verification. public struct ReadContainer { // MARK: Nested Types - public struct LengthExceededError: Error {} + /// Thrown by ``consume(_:)`` when the requested byte count would read past the end of + /// the (sub-)container. + public struct LengthExceededError: Error, Sendable {} // MARK: Stored Properties + /// The full byte buffer this container is reading from. For a `Scope` sub-container, + /// this is the slice covering only the scoped region. public let data: Data + + /// The cursor position. Bytes before `index` have been consumed by the format walk so + /// far; bytes from `index` onward are still to be read. public var index: Data.Index + + /// The active environment (endianness, suffix terminator, etc.). Modifiers such as + /// `.endianness(.big)` create a child container with a transformed environment, then + /// restore the previous environment afterwards. public var environment: EnvironmentValues // MARK: Computed Properties + /// Bytes from the start of `data` up to (but not including) `index` — i.e. everything + /// that has been consumed so far in this container. Used as the input range for + /// `ChecksumProperty`. public var consumedData: Data { data.prefix(upTo: index) } + /// Bytes from `index` to the end of `data` — i.e. what is still left to read. public var remainingData: Data { data.suffix(from: index) } // MARK: Initialization + /// Creates a container over `data` with an initial cursor and environment. + /// + /// - Parameters: + /// - data: The bytes to read. + /// - index: The starting cursor. Defaults to `data.startIndex`. + /// - environment: The initial environment. public init( data: Data, index: Data.Index? = nil, @@ -43,6 +68,12 @@ public struct ReadContainer { // MARK: Methods + /// Consumes the next `count` bytes from the cursor and returns them as a slice. + /// + /// The cursor is advanced past the returned range. If the container has fewer than + /// `count` bytes remaining, the cursor is left unchanged and an error is thrown. + /// + /// - Throws: ``LengthExceededError`` if `count` exceeds the number of bytes remaining. public mutating func consume(_ count: Int) throws -> Data { guard count <= data.distance(from: index, to: data.endIndex) else { throw LengthExceededError() diff --git a/Sources/DataKit/Readable/ReadContext.swift b/Sources/DataKit/Readable/ReadContext.swift index 51399d4..4f3f41b 100644 --- a/Sources/DataKit/Readable/ReadContext.swift +++ b/Sources/DataKit/Readable/ReadContext.swift @@ -1,22 +1,46 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 22.06.23. -// +// ReadContext.swift import Foundation +/// A keyed scratchpad that bridges the format walk and the `init(from:)` initializer. +/// +/// `ReadContext` is the link between the two phases of decoding a `Readable`: +/// +/// 1. The format walk parses values from the input bytes and stores each one keyed by a +/// `KeyPath`. This is done implicitly when you write `\.foo` in a format +/// declaration or explicitly via `Property(\.foo)`. +/// 2. Your `init(from context: ReadContext)` initializer pulls those values back out +/// using the *same* key paths. +/// +/// The key paths used in the format walk and in the initializer must match exactly. Mixing +/// them up is not caught by the compiler; instead `read(for:)` throws ``ValueDoesNotExistError`` +/// at runtime when the key path was never written, or ``ValueTypeMismatchError`` when the +/// stored type does not match (typically because a `Convert` placed a converted value in +/// the slot). public struct ReadContext { // MARK: Nested Types - public struct ValueDoesNotExistError: Error { + /// Thrown by ``ReadContext/read(for:)`` when no value has been written for the requested key path. + /// + /// This usually indicates a mismatch between the key paths used in the format declaration + /// and in `init(from:)`, or a conditional branch in the format that never executed. + public struct ValueDoesNotExistError: Error, @unchecked Sendable { + + /// The key path for which no value was found. public let keyPath: PartialKeyPath } - public struct ValueTypeMismatchError: Error { + /// Thrown by ``ReadContext/read(for:)`` when the stored value's type does not match the requested type. + /// + /// Most often this means a value placed via `Convert` was stored under a different runtime + /// type than the one requested. + public struct ValueTypeMismatchError: Error, @unchecked Sendable { + + /// The actual value that was stored. public let value: Any + + /// The type that was requested. public let expectedType: Any.Type } @@ -26,14 +50,30 @@ public struct ReadContext { // MARK: Initialization + /// Creates an empty context. Library users do not call this directly — the framework + /// constructs a fresh context at the start of each read. public init() {} // MARK: Methods + /// Stores `value` under `keyPath` so that it can later be retrieved by the matching + /// `read(for:)`/`readIfPresent(for:)` call in `init(from:)`. + /// + /// Implementers of custom `ReadableProperty` types call this from their `read` method. + /// An existing value at the same key path is overwritten without warning. + /// + /// - Throws: This method does not currently throw, but is declared `throws` so that + /// future implementations may add validation. public mutating func write(_ value: Value, for keyPath: KeyPath) throws { values[keyPath] = value } + /// Retrieves the value stored under `keyPath`. + /// + /// - Parameter keyPath: The key path under which the format walk stored a value. + /// - Returns: The stored value. + /// - Throws: ``ValueDoesNotExistError`` if no value was written for the key path. + /// ``ValueTypeMismatchError`` if a value was written but its runtime type does not match `Value`. public func read(for keyPath: KeyPath) throws -> Value { guard let value = values[keyPath] else { throw ValueDoesNotExistError(keyPath: keyPath) @@ -44,6 +84,14 @@ public struct ReadContext { return result } + /// Retrieves the value stored under `keyPath`, or `nil` if no value was written. + /// + /// Use this overload for fields whose presence depends on a runtime condition in the + /// format (such as a feature flag). A missing value yields `nil`; a value of the + /// wrong type still throws. + /// + /// - Throws: ``ValueTypeMismatchError`` if a value was written but its runtime type + /// does not match `Value`. public func readIfPresent(for keyPath: KeyPath) throws -> Value? { guard let value = values[keyPath] else { return nil @@ -54,6 +102,14 @@ public struct ReadContext { return result } + /// Retrieves the value stored under an optional-typed key path. + /// + /// This overload exists for properties declared `Optional` on `Root` so that the + /// caller can write `try context.readIfPresent(for: \.maybeField)` and recover the + /// unwrapped `Wrapped` value directly. + /// + /// - Throws: ``ValueTypeMismatchError`` if a value was written but its runtime type + /// does not match `Value`. public func readIfPresent(for keyPath: KeyPath) throws -> Value? { guard let value = values[keyPath] else { return nil diff --git a/Sources/DataKit/Readable/Readable.swift b/Sources/DataKit/Readable/Readable.swift index 92d4ee5..4001ccd 100644 --- a/Sources/DataKit/Readable/Readable.swift +++ b/Sources/DataKit/Readable/Readable.swift @@ -1,16 +1,56 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 21.06.23. -// +// Readable.swift import Foundation +/// A type that can be decoded from binary `Data` using a declarative format. +/// +/// Conforming types provide two things: +/// +/// - A static ``readFormat`` that uses the `@ReadBuilder` result builder DSL to describe the +/// byte layout. Each statement parses bytes and stores values into a `ReadContext`, +/// keyed by `KeyPath`. +/// - An initializer ``init(from:)`` that constructs the value from the populated `ReadContext`. +/// +/// The key paths used in `readFormat` and in `init(from:)` must match. Mismatches are not +/// caught by the compiler and surface as `ReadContext.ValueDoesNotExistError` at runtime. +/// +/// A simple example: +/// +/// ```swift +/// struct Header: Readable { +/// var magic: UInt16 +/// var count: UInt8 +/// +/// init(from context: ReadContext
) throws { +/// magic = try context.read(for: \.magic) +/// count = try context.read(for: \.count) +/// } +/// +/// static var readFormat: ReadFormat
{ +/// \.magic +/// \.count +/// } +/// } +/// +/// let header = try Header(data) +/// ``` +/// +/// If your type round-trips in both directions, prefer ``ReadWritable`` so you only declare +/// the format once. public protocol Readable { + /// Builds the value from the populated context after the format walk has run. + /// + /// - Parameter context: A scratchpad populated by ``readFormat``, keyed by `KeyPath`. + /// - Throws: Errors raised by ``ReadContext/read(for:)`` and ``ReadContext/readIfPresent(for:)``, + /// or any error thrown by the implementer. init(from context: ReadContext) throws + /// The declarative description of how to parse `Self` from bytes. + /// + /// Each statement runs in order against the active `ReadContainer`. A bare key path + /// (e.g. `\.magic`) is equivalent to `Property(\.magic)`: it parses a `Value` from the + /// container and stores it in the `ReadContext` under that key path. @ReadBuilder static var readFormat: ReadFormat { get throws } @@ -20,17 +60,35 @@ extension Readable { public typealias ReadBuilder = ReadFormatBuilder + /// Reads `Self` from an in-flight `ReadContainer`. Used internally when one `Readable` + /// is nested inside another's format. + /// + /// - Throws: Whatever ``readFormat`` and ``init(from:)`` throw. public init(from container: inout ReadContainer) throws { var context = ReadContext() try Self.readFormat.read(from: &container, context: &context) try self.init(from: context) } + /// Decodes `Self` from a complete `Data` value. + /// + /// - Parameters: + /// - data: The raw bytes to decode. + /// - environment: Initial environment values (endianness, suffix terminator, etc.). + /// - Throws: Whatever ``readFormat`` and ``init(from:)`` throw. public init(_ data: Data, environment: EnvironmentValues = EnvironmentValues()) throws { var container = ReadContainer(data: data, environment: environment) try self.init(from: &container) } + /// Decodes `Self` from a complete `Data` value, configuring the environment via a closure. + /// + /// Use this overload when you want to set environment values inline at the call site, + /// for example to enforce a particular endianness: + /// + /// ```swift + /// let value = try Packet(data) { $0.endianness = .big } + /// ``` public init(_ data: Data, transform: (inout EnvironmentValues) throws -> Void) throws { var environment = EnvironmentValues() try transform(&environment) diff --git a/Sources/DataKit/Readable/ReadableProperty.swift b/Sources/DataKit/Readable/ReadableProperty.swift index 7fcd59f..97b8a65 100644 --- a/Sources/DataKit/Readable/ReadableProperty.swift +++ b/Sources/DataKit/Readable/ReadableProperty.swift @@ -1,16 +1,22 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 21.06.23. -// +// ReadableProperty.swift import Foundation +/// A ``FormatProperty`` that can decode bytes from a ``ReadContainer`` into a ``ReadContext``. public protocol ReadableProperty: FormatProperty where Root: Readable { + + /// Parses values from `container` and stores them into `context`. + /// + /// - Throws: Any decoding error, including ``ReadContainer/LengthExceededError``, + /// ``ConversionError``, or ``UnexpectedValueError``. func read(from container: inout ReadContainer, context: inout ReadContext) throws } +/// A type-erased read format produced by a `@ReadBuilder` block. +/// +/// Most users encounter `ReadFormat` only as the return type of `static var readFormat`. +/// The `init(read:)` initializer is an escape hatch for advanced users who want to drop +/// down to imperative reading. public struct ReadFormat: ReadableProperty { // MARK: Stored Properties @@ -19,6 +25,7 @@ public struct ReadFormat: ReadableProperty { // MARK: Initialization + /// Wraps an imperative read closure as a `ReadFormat`. public init(read: @escaping (inout ReadContainer, inout ReadContext) throws -> Void) { self._read = read } @@ -32,6 +39,8 @@ public struct ReadFormat: ReadableProperty { } extension ReadFormat: FormatType { + + /// Sequentially composes multiple `ReadFormat`s into one. public init(_ multiple: [ReadFormat]) { self.init { container, context in for format in multiple { @@ -40,3 +49,4 @@ extension ReadFormat: FormatType { } } } + diff --git a/Sources/DataKit/Sendable.swift b/Sources/DataKit/Sendable.swift new file mode 100644 index 0000000..862fbe3 --- /dev/null +++ b/Sources/DataKit/Sendable.swift @@ -0,0 +1,67 @@ +// Sendable.swift +// +// `Sendable` conformances for DataKit's value-type formats, conversions, containers, and +// environment. +// +// Two design notes: +// +// 1. Several types here are marked `@unchecked Sendable` rather than `Sendable`. The reason +// is almost always one of: (a) they store an `@escaping` closure whose generic +// parameters Swift cannot statically prove sendable; (b) they store a `KeyPath`, which +// only gained automatic `Sendable` inference in Swift 5.10 — DataKit's tools floor is +// 5.9. All such structs are immutable: their stored properties are `let`-bound and they +// have no mutating methods, so concurrent use is sound in practice. If you add a +// mutable stored property or a mutable method, revisit the conformance. +// +// 2. Closures supplied by users at the call site (e.g. inside `Custom`, `Convert`, `Using`) +// are not marked `@Sendable` in the public initializers. That would be a source-breaking +// API change. Capturing non-`Sendable` mutable state from such a closure and then +// sending the resulting `FormatProperty` across actor boundaries is undefined behavior +// today; document this as a known limitation rather than fix it via API breakage. + +import Foundation + +// MARK: - Containers + +extension ReadContainer: @unchecked Sendable {} +extension WriteContainer: @unchecked Sendable {} +extension ReadContext: @unchecked Sendable {} + +// MARK: - Environment + +extension EnvironmentValues: @unchecked Sendable {} +// `Endianness: Sendable` and `Suffix: Sendable` are declared in their own source files +// because Swift 6 requires checked `Sendable` conformances to be co-located with the type. + +// MARK: - Format types + +extension ReadFormat: @unchecked Sendable {} +extension WriteFormat: @unchecked Sendable {} +extension ReadWriteFormat: @unchecked Sendable {} + +// MARK: - Format-property primitives + +extension Property: @unchecked Sendable {} +extension Scope: @unchecked Sendable {} +extension Using: @unchecked Sendable {} +extension Custom: @unchecked Sendable {} +extension Convert: @unchecked Sendable {} +extension Environment: @unchecked Sendable {} +extension EnvironmentProperty: @unchecked Sendable {} +extension OnRead: @unchecked Sendable {} +extension OnWrite: @unchecked Sendable {} +extension ChecksumProperty: @unchecked Sendable {} + +// MARK: - Conversions + +extension Conversion: @unchecked Sendable {} +extension ReversibleConversion: @unchecked Sendable {} + +// MARK: - Sequence wrappers +// +// `PrefixCountArray` and `DynamicCountArray` declare their conditional `Sendable` +// conformance inline in their own source files (same-file rule for checked conformances). + +// MARK: - Result-builder accumulator + +extension DataBuilder.Component: @unchecked Sendable {} diff --git a/Sources/DataKit/Values/ReadWritable+FloatingPoint.swift b/Sources/DataKit/Values/ReadWritable+FloatingPoint.swift index 9f86169..35e334d 100644 --- a/Sources/DataKit/Values/ReadWritable+FloatingPoint.swift +++ b/Sources/DataKit/Values/ReadWritable+FloatingPoint.swift @@ -1,12 +1,14 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 16.07.23. -// +// ReadWritable+FloatingPoint.swift import Foundation +/// A floating-point type whose bit pattern is a `FixedWidthInteger`, allowing it to be +/// serialized by reusing the integer encoding (and thus the current +/// ``EnvironmentValues/endianness``). +/// +/// You will not normally conform your own types to this protocol — the standard library +/// floating-point types (`Float16` on arm64-only platforms, `Float32`, `Float64`) already +/// conform. public protocol FixedWidthFloatingPoint: BinaryFloatingPoint { associatedtype BitPattern: FixedWidthInteger diff --git a/Sources/DataKit/Values/ReadWritable+Integer.swift b/Sources/DataKit/Values/ReadWritable+Integer.swift index c6a9496..b372b62 100644 --- a/Sources/DataKit/Values/ReadWritable+Integer.swift +++ b/Sources/DataKit/Values/ReadWritable+Integer.swift @@ -1,12 +1,14 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 16.07.23. -// +// ReadWritable+Integer.swift import Foundation +// All standard-library fixed-width integer types are `ReadWritable` out of the box. +// +// The on-wire size of an integer is `MemoryLayout.size`. The byte order is taken +// from `EnvironmentValues.endianness`; when that is `nil` (the default), host-native byte +// order is used — which is portable across same-endian machines only, and is rarely what a +// cross-platform wire protocol wants. + extension Int: ReadWritable {} extension Int8: ReadWritable {} extension Int16: ReadWritable {} @@ -69,5 +71,3 @@ extension FixedWidthInteger where Self: ReadWritable { } } - - diff --git a/Sources/DataKit/Values/ReadWritable+Optional.swift b/Sources/DataKit/Values/ReadWritable+Optional.swift index f8833fe..48c3a60 100644 --- a/Sources/DataKit/Values/ReadWritable+Optional.swift +++ b/Sources/DataKit/Values/ReadWritable+Optional.swift @@ -1,12 +1,16 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 16.07.23. -// +// ReadWritable+Optional.swift import Foundation +/// Optional handling in DataKit is asymmetric and worth knowing about explicitly: +/// +/// - **Reading** an `Optional` always parses a `Wrapped` and produces `.some(value)`. +/// The `Optional` typing exists for the *write* side; you never get `nil` back from a +/// read — to make presence optional, use ``Using`` plus +/// ``ReadContext/readIfPresent(for:)`` to conditionally decode. +/// - **Writing** a `nil` produces zero bytes; writing `.some` emits the wrapped value. This +/// is useful for fields whose presence depends on an earlier flag. + extension Optional: Readable where Wrapped: Readable { public init(from context: ReadContext) throws { diff --git a/Sources/DataKit/Values/ReadWritable+Raw.swift b/Sources/DataKit/Values/ReadWritable+Raw.swift index 53183e1..d7d07e7 100644 --- a/Sources/DataKit/Values/ReadWritable+Raw.swift +++ b/Sources/DataKit/Values/ReadWritable+Raw.swift @@ -1,12 +1,11 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 16.07.23. -// +// ReadWritable+Raw.swift import Foundation +// `RawRepresentable` types whose `RawValue` is itself `ReadWritable` (the common case for +// enums backed by integer raw values) automatically gain the corresponding conformance. +// Unknown raw values during decode throw `ConversionError`. + extension RawRepresentable where Self: Readable, RawValue: Readable { public init(from context: ReadContext) throws { diff --git a/Sources/DataKit/Writable/Writable.swift b/Sources/DataKit/Writable/Writable.swift index b0f0ee7..1c911dd 100644 --- a/Sources/DataKit/Writable/Writable.swift +++ b/Sources/DataKit/Writable/Writable.swift @@ -1,14 +1,36 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 21.06.23. -// +// Writable.swift import Foundation +/// A type that can be encoded to binary `Data` using a declarative format. +/// +/// Conforming types provide a static ``writeFormat`` that describes the byte layout. Unlike +/// ``Readable``, no scratchpad is involved: the format walk reads values directly off `self` +/// using the key paths in the declaration. +/// +/// ```swift +/// struct Header: Writable { +/// var magic: UInt16 +/// var count: UInt8 +/// +/// static var writeFormat: WriteFormat
{ +/// \.magic +/// \.count +/// } +/// } +/// +/// let bytes: Data = try header.write() +/// ``` +/// +/// If your type round-trips in both directions, prefer ``ReadWritable`` so you only declare +/// the format once. public protocol Writable { + /// The declarative description of how to serialize `Self` into bytes. + /// + /// Each statement runs in order against the active `WriteContainer`. A bare key path + /// (e.g. `\.magic`) is equivalent to `Property(\.magic)`: it reads the property value + /// off `self` and appends its bytes. @WriteBuilder static var writeFormat: WriteFormat { get throws } @@ -18,16 +40,30 @@ extension Writable { public typealias WriteBuilder = WriteFormatBuilder + /// Writes `self` into an in-flight `WriteContainer`. Used internally when one + /// `Writable` is nested inside another's format. + /// + /// - Throws: Whatever ``writeFormat`` throws. public func write(to container: inout WriteContainer) throws { try Self.writeFormat.write(to: &container, using: self) } + /// Encodes `self` into a fresh `Data` value. + /// + /// - Parameter environment: Initial environment values (endianness, suffix terminator, etc.). + /// - Returns: The serialized bytes. + /// - Throws: Whatever ``writeFormat`` throws. public func write(with environment: EnvironmentValues = EnvironmentValues()) throws -> Data { var container = WriteContainer(environment: environment) try write(to: &container) return container.data } + /// Encodes `self` into a fresh `Data` value, configuring the environment via a closure. + /// + /// ```swift + /// let bytes = try packet.write { $0.endianness = .big } + /// ``` public func write(transform: (inout EnvironmentValues) throws -> Void) throws -> Data { var environment = EnvironmentValues() try transform(&environment) diff --git a/Sources/DataKit/Writable/WritableProperty.swift b/Sources/DataKit/Writable/WritableProperty.swift index 97f132e..d7b87e2 100644 --- a/Sources/DataKit/Writable/WritableProperty.swift +++ b/Sources/DataKit/Writable/WritableProperty.swift @@ -1,16 +1,21 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 21.06.23. -// +// WritableProperty.swift import Foundation +/// A ``FormatProperty`` that can encode a value of `Root` into a ``WriteContainer``. public protocol WritableProperty: FormatProperty where Root: Writable { + + /// Appends bytes derived from `root` to `container`. + /// + /// - Throws: Any encoding error, including ``ConversionError``. func write(to container: inout WriteContainer, using root: Root) throws } +/// A type-erased write format produced by a `@WriteBuilder` block. +/// +/// Most users encounter `WriteFormat` only as the return type of `static var writeFormat`. +/// The `init(write:)` initializer is an escape hatch for advanced users who want to drop +/// down to imperative writing. public struct WriteFormat: WritableProperty { // MARK: Stored Properties @@ -19,6 +24,7 @@ public struct WriteFormat: WritableProperty { // MARK: Initialization + /// Wraps an imperative write closure as a `WriteFormat`. public init(write: @escaping (inout WriteContainer, Root) throws -> Void) { self._write = write } @@ -33,6 +39,7 @@ public struct WriteFormat: WritableProperty { extension WriteFormat: FormatType { + /// Sequentially composes multiple `WriteFormat`s into one. public init(_ multiple: [WriteFormat]) { self.init { container, root in for format in multiple { @@ -42,3 +49,4 @@ extension WriteFormat: FormatType { } } + diff --git a/Sources/DataKit/Writable/WriteContainer.swift b/Sources/DataKit/Writable/WriteContainer.swift index 4fe21c6..341facb 100644 --- a/Sources/DataKit/Writable/WriteContainer.swift +++ b/Sources/DataKit/Writable/WriteContainer.swift @@ -1,21 +1,31 @@ -// -// File.swift -// -// -// Created by Paul Kraft on 26.06.23. -// +// WriteContainer.swift import Foundation +/// The mutable write cursor passed through the format walk during serialization. +/// +/// A `WriteContainer` accumulates `data` and carries the active `EnvironmentValues`. +/// Format properties append bytes via the `append(...)` overloads or via `transform(_:)` +/// for direct mutable access (useful when back-patching, e.g. writing a value and then +/// updating an earlier length field). +/// +/// Unlike `ReadContainer`, a write container does not maintain a separate cursor — every +/// append moves the end of `data` forward. To carve out a sub-buffer (for example, to +/// compute a checksum over a contained region), use a `Scope`. public struct WriteContainer { // MARK: Stored Properties + /// The bytes accumulated so far. Read-only externally; grows via the `append(...)` + /// methods or in-place mutation through `transform(_:)`. public private(set) var data: Data + + /// The active environment (endianness, suffix terminator, etc.). public var environment: EnvironmentValues // MARK: Initialization + /// Creates a write container with an optional pre-populated buffer. public init( data: Data = Data(), environment: EnvironmentValues @@ -26,22 +36,28 @@ public struct WriteContainer { // MARK: Methods + /// Gives the caller mutable access to the underlying buffer. Useful for in-place edits + /// such as back-patching a length field after the payload has been written. public mutating func transform(_ transform: (inout Data) throws -> V) rethrows -> V { try transform(&data) } + /// Appends the bytes of `newData` to the buffer. public mutating func append(_ newData: Data) { data.append(newData) } + /// Appends the contents of an unsafe buffer pointer to the buffer. public mutating func append(_ buffer: UnsafeBufferPointer) { data.append(buffer) } + /// Appends a sequence of bytes to the buffer. public mutating func append(contentsOf bytes: [UInt8]) { data.append(contentsOf: bytes) } + /// Appends an arbitrary `Sequence` of bytes to the buffer. public mutating func append>(contentsOf elements: S) { data.append(contentsOf: elements) } From 28db1e0cb58af47b3a915dbddad5165304bedf91 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Fri, 29 May 2026 12:14:56 +0200 Subject: [PATCH 2/4] update --- .github/workflows/ci.yml | 7 +- CHANGELOG.md | 94 +++++++++++++++ Package.swift | 2 +- README.md | 13 ++- .../DataKit/Builder/FormatBuilder+Read.swift | 6 +- .../Builder/FormatBuilder+ReadWrite.swift | 2 +- .../DataKit/Builder/FormatBuilder+Write.swift | 6 +- Sources/DataKit/DataKit.docc/DataKit.md | 4 + .../DataKit.docc/Migrating-to-0.2.0.md | 110 ++++++++++++++++++ Sources/DataKit/Property/Checksum.swift | 36 +++--- Sources/DataKit/Property/Convert.swift | 40 ++++--- Sources/DataKit/Property/Custom.swift | 16 +-- Sources/DataKit/Property/KeyPath.swift | 37 +++--- Sources/DataKit/Property/OnRead.swift | 2 + Sources/DataKit/Property/OnWrite.swift | 2 + .../Property/Property+Conversion.swift | 14 +-- .../DataKit/Property/Property+Custom.swift | 14 +-- Sources/DataKit/Property/Property.swift | 3 + Sources/DataKit/Property/Scope.swift | 2 + Sources/DataKit/ReadWritable/Format.swift | 2 +- .../DataKit/ReadWritable/ReadWritable.swift | 10 +- .../ReadWritable/ReadWritableProperty.swift | 2 + Sources/DataKit/Readable/Readable.swift | 2 +- .../DataKit/Readable/ReadableProperty.swift | 8 +- Sources/DataKit/Sendable.swift | 55 +++------ .../Values/ReadWritable+FloatingPoint.swift | 2 +- .../DataKit/Values/ReadWritable+Integer.swift | 2 +- .../Values/ReadWritable+Optional.swift | 6 +- Sources/DataKit/Values/ReadWritable+Raw.swift | 6 +- Sources/DataKit/Writable/Writable.swift | 2 +- .../DataKit/Writable/WritableProperty.swift | 8 +- 31 files changed, 368 insertions(+), 147 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 Sources/DataKit/DataKit.docc/Migrating-to-0.2.0.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ca3425..20ee545 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,8 @@ on: jobs: macos: - name: macOS (Swift ${{ matrix.swift }}) + name: macOS runs-on: macos-14 - strategy: - matrix: - swift: ["5.9", "5.10"] steps: - uses: actions/checkout@v4 - uses: maxim-lobanov/setup-xcode@v1 @@ -29,7 +26,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - swift: ["5.9", "5.10"] + swift: ["5.10"] steps: - uses: actions/checkout@v4 - uses: swift-actions/setup-swift@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..952b4d0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,94 @@ +# Changelog + +All notable changes to DataKit are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +While the major version is `0`, breaking changes are released as minor version bumps. + +## [Unreleased] + +_Nothing yet._ + +## [0.2.0] - 2026-05-29 + +First release of the modernized DataKit. This is a maintenance-focused release: no new +format features, but a significant pass over packaging, documentation, and Swift +concurrency support. It contains source-breaking changes — see +[Migrating to 0.2.0](Sources/DataKit/DataKit.docc/Migrating-to-0.2.0.md) for a guided +upgrade. + +### Added + +- **Full Swift Concurrency support.** The public API now conforms to `Sendable`, so + formats, conversions, and values can be shared across actor and task boundaries. +- **Documentation.** Every public symbol now carries a `///` doc comment, and a DocC + catalog (`DataKit.docc`) introduces the two-phase read model, the environment system, + and the conversion pipeline. Hosted documentation is available on the + [Swift Package Index](https://swiftpackageindex.com/QuickBirdEng/DataKit/documentation). +- **Declared platforms.** `Package.swift` now declares minimum platforms explicitly: + iOS 13, macOS 10.15, tvOS 13, watchOS 6. +- **Continuous integration.** A GitHub Actions workflow builds and tests on macOS and + Linux. +- **`.spi.yml`** so the Swift Package Index builds and hosts the DocC documentation. +- **`NOTES.md`** tracking candidate future features (error context enrichment, `Bounded`, + `BitField`, a `DataKitTesting` target, additional conversions). + +### Changed + +- **Swift tools version raised from 5.4 to 5.10.** 5.4 could not actually compile the + result-builder code (`buildPartialBlock` requires 5.7); 5.10 is the supported floor and + enables checked `Sendable` conformances on key-path-backed types. +- The library target enables the `StrictConcurrency` and `ExistentialAny` upcoming + features. +- The README has been restructured for approachability: a quick-start example up front, a + concepts reference, and an explicit requirements section. + +### Fixed + +- **`skipChecksumVerification` is now honored.** `ChecksumProperty` previously read the + environment value but never consulted it, so checksums were always verified even when + verification had been disabled. The flag now correctly suppresses verification while + still consuming the checksum bytes. +- Corrected the broken `crc-swift` repository link in the README (`crc-swift.org` → + `crc-swift`). + +### Removed + +- **`CannotWriteNilError`.** This error type was declared publicly but never thrown + anywhere in the library. (Breaking only for code that referenced the type by name.) + +### Breaking Changes + +These require source changes in dependent code. See +[Migrating to 0.2.0](Sources/DataKit/DataKit.docc/Migrating-to-0.2.0.md) for details and +fixes. + +- Conforming types of `Readable`, `Writable`, and `ReadWritable` must now be `Sendable`. + All standard-library conformers already satisfy this. +- `FormatProperty` — and therefore `ReadableProperty` and `WritableProperty` — now refine + `Sendable`; their `Root` must be `Sendable`. +- Closures passed to `Custom` and `Convert` (and the fluent `Property.read`/`.write`/ + `.converted` helpers) must now be `@Sendable`. +- `Checksum` types used inside a format must be `Sendable`. +- `KeyPath` no longer conforms to `FormatProperty` / `ReadableProperty` / + `WritableProperty` directly. Bare key-path syntax (`\.field`) inside a format builder + still works; passing a key path where a `FormatProperty` is expected **outside** a + builder now requires wrapping it explicitly as `Property(\.field)`. +- Minimum Swift toolchain is now 5.10 (previously declared 5.4). + +## [0.1.1] - 2024-03-14 + +### Fixed + +- Bumped `crc-swift` to 0.1.1. +- Fixed a compilation issue on x86_64 hardware. + +## [0.1.0] + +- Initial public release. + +[Unreleased]: https://github.com/QuickBirdEng/DataKit/compare/0.2.0...HEAD +[0.2.0]: https://github.com/QuickBirdEng/DataKit/compare/0.1.1...0.2.0 +[0.1.1]: https://github.com/QuickBirdEng/DataKit/compare/0.1.0...0.1.1 +[0.1.0]: https://github.com/QuickBirdEng/DataKit/releases/tag/0.1.0 diff --git a/Package.swift b/Package.swift index 786c306..72a3788 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 5.10 import PackageDescription diff --git a/README.md b/README.md index 11855b9..5566dba 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - Handles real-world wire-protocol concerns: endianness, bit-packed flags, length prefixes, dynamic suffixes, and CRC checksums. [![Swift Package Manager](https://img.shields.io/badge/SwiftPM-compatible-brightgreen.svg)](https://swift.org/package-manager) -[![Swift](https://img.shields.io/badge/Swift-5.9%2B-orange.svg)](https://swift.org) +[![Swift](https://img.shields.io/badge/Swift-5.10%2B-orange.svg)](https://swift.org) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Documentation](https://swiftpackageindex.com/QuickBirdEng/DataKit/documentation)](https://swiftpackageindex.com/QuickBirdEng/DataKit/documentation) @@ -30,7 +30,7 @@ Add DataKit to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/QuickBirdEng/DataKit.git", from: "0.1.0"), + .package(url: "https://github.com/QuickBirdEng/DataKit.git", from: "0.2.0"), ], ``` @@ -306,13 +306,18 @@ format. ## Requirements -- Swift 5.9+ -- iOS 13+, macOS 10.15+, tvOS 13+, watchOS 6+, Linux (Swift 5.9+) +- Swift 5.10+ +- iOS 13+, macOS 10.15+, tvOS 13+, watchOS 6+, Linux (Swift 5.10+) - Single dependency: [crc-swift](https://github.com/QuickBirdEng/crc-swift) (re-exported as `CRC`) Documentation is hosted at [Swift Package Index](https://swiftpackageindex.com/QuickBirdEng/DataKit/documentation). +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for release notes. Upgrading from 0.1.x? Read the +[migration guide](Sources/DataKit/DataKit.docc/Migrating-to-0.2.0.md). + ## Contributing Issues and PRs welcome. Before opening a PR: diff --git a/Sources/DataKit/Builder/FormatBuilder+Read.swift b/Sources/DataKit/Builder/FormatBuilder+Read.swift index e6568b9..03902b4 100644 --- a/Sources/DataKit/Builder/FormatBuilder+Read.swift +++ b/Sources/DataKit/Builder/FormatBuilder+Read.swift @@ -22,7 +22,7 @@ extension FormatBuilder where Root: Readable, Format == ReadFormat { /// A bare ``Checksum`` value in a `@ReadBuilder` block reads and verifies the checksum /// bytes (always in big-endian). To control the input range, wrap the relevant section /// in a ``Scope``. - public static func buildExpression(_ expression: C) -> Format where C.Value: Readable { + public static func buildExpression(_ expression: C) -> Format where C.Value: Readable { buildExpression( ReadFormat { container, _ in let verificationData = container.consumedData @@ -35,7 +35,9 @@ extension FormatBuilder where Root: Readable, Format == ReadFormat { /// Any ``ReadableProperty`` (e.g. ``Property``, ``Convert``, ``Custom``, ``Scope``) /// participates in a read builder. public static func buildExpression(_ expression: V) -> Format where V.Root == Root { - .init(read: expression.read) + .init { container, context in + try expression.read(from: &container, context: &context) + } } /// Bare key-path syntax (`\.field`) lifts to ``Property``. diff --git a/Sources/DataKit/Builder/FormatBuilder+ReadWrite.swift b/Sources/DataKit/Builder/FormatBuilder+ReadWrite.swift index 4fcf381..dabb6a4 100644 --- a/Sources/DataKit/Builder/FormatBuilder+ReadWrite.swift +++ b/Sources/DataKit/Builder/FormatBuilder+ReadWrite.swift @@ -36,7 +36,7 @@ extension FormatBuilder where Root: ReadWritable, Format == ReadWriteFormat(_ expression: C) -> Format where C.Value: ReadWritable { + public static func buildExpression(_ expression: C) -> Format where C.Value: ReadWritable { .init( read: ReadFormatBuilder.buildExpression(expression), write: WriteFormatBuilder.buildExpression(expression) diff --git a/Sources/DataKit/Builder/FormatBuilder+Write.swift b/Sources/DataKit/Builder/FormatBuilder+Write.swift index db82c01..0de8b5b 100644 --- a/Sources/DataKit/Builder/FormatBuilder+Write.swift +++ b/Sources/DataKit/Builder/FormatBuilder+Write.swift @@ -10,7 +10,9 @@ extension FormatBuilder where Root: Writable, Format == WriteFormat { /// Any ``WritableProperty`` (e.g. ``Property``, ``Convert``, ``Custom``, ``Scope``) /// participates in a write builder. public static func buildExpression(_ expression: V) -> Format where V.Root == Format.Root { - .init(write: expression.write) + .init { container, root in + try expression.write(to: &container, using: root) + } } /// A ``Writable`` literal in a `@WriteBuilder` block is serialized verbatim — used for @@ -29,7 +31,7 @@ extension FormatBuilder where Root: Writable, Format == WriteFormat { /// A bare ``Checksum`` value in a `@WriteBuilder` block computes the checksum over the /// buffer accumulated so far and appends it (always in big-endian). To control the /// input range, wrap the relevant section in a ``Scope``. - public static func buildExpression(_ expression: C) -> Format where C.Value: Writable { + public static func buildExpression(_ expression: C) -> Format where C.Value: Writable { buildExpression( WriteFormat { container, _ in try expression.calculate(for: container.data) diff --git a/Sources/DataKit/DataKit.docc/DataKit.md b/Sources/DataKit/DataKit.docc/DataKit.md index 4cb92f7..6b69ac8 100644 --- a/Sources/DataKit/DataKit.docc/DataKit.md +++ b/Sources/DataKit/DataKit.docc/DataKit.md @@ -74,6 +74,10 @@ own. ## Topics +### Release Notes + +- + ### Core Protocols - ``Readable`` diff --git a/Sources/DataKit/DataKit.docc/Migrating-to-0.2.0.md b/Sources/DataKit/DataKit.docc/Migrating-to-0.2.0.md new file mode 100644 index 0000000..1b17534 --- /dev/null +++ b/Sources/DataKit/DataKit.docc/Migrating-to-0.2.0.md @@ -0,0 +1,110 @@ +# Migrating to 0.2.0 + +Upgrade dependent code from DataKit 0.1.x to 0.2.0. + +## Overview + +DataKit 0.2.0 adds full Swift Concurrency (`Sendable`) support and raises the minimum +Swift toolchain. There are no new format features and no behavior changes to existing, +correct code — but the new `Sendable` requirements and the removal of a couple of +incidental APIs are source-breaking. Most projects need only small, mechanical changes. + +If your build was already clean under the Swift 6 language mode, you will likely need no +changes at all. + +## Raise your toolchain to Swift 5.10 + +DataKit now declares `swift-tools-version: 5.10` and minimum platforms of iOS 13 / +macOS 10.15 / tvOS 13 / watchOS 6. Build with a Swift 5.10 or newer toolchain. (The +previously declared 5.4 floor was never actually buildable — the result-builder code +requires 5.7.) + +## Make your types `Sendable` + +`Readable`, `Writable`, and `ReadWritable` now refine `Sendable`. Every conforming type +must therefore be `Sendable`. + +Value types whose stored properties are all `Sendable` get this for free — no annotation +needed. All standard-library conformers (integers, floating-point, `String`, +`RawRepresentable` enums, `Optional`) already satisfy it. You only need to act if one of +your conforming types stores a non-`Sendable` value: + +```swift +// Before — compiled in 0.1.x +struct Packet: ReadWritable { + var payload: NSMutableData // not Sendable + // ... +} + +// After — make the type's storage Sendable, or mark the conformance @unchecked +// with a documented invariant if you are certain concurrent use is safe. +struct Packet: ReadWritable { + var payload: Data // Sendable + // ... +} +``` + +The same applies to custom `FormatProperty` / `ReadableProperty` / `WritableProperty` +implementations: these protocols now refine `Sendable`, and their `Root` must be +`Sendable`. + +## Mark `Custom` and `Convert` closures `@Sendable` + +Closures passed to ``Custom`` and ``Convert`` (and the fluent ``Property`` helpers +`read`, `write`, `converted`) are now `@Sendable`. In practice this means a closure may no +longer capture non-`Sendable` mutable state. Most format closures are pure transforms and +need no change: + +```swift +// Still compiles — pure transform, no captured mutable state +Convert(\.humidity) { Double($0) / 100 } writing: { UInt8($0 * 100) } +``` + +If a closure captures something non-`Sendable`, refactor so the captured value is either +`Sendable` or passed through the container/environment instead of captured. + +## Use `Checksum` types that are `Sendable` + +Checksum algorithms used inside a format must now be `Sendable`. The `CRC32`, `CRC16`, +etc. types from the re-exported `CRC` module already are, so standard usage is unaffected. +A custom `Checksum` conformer must be made `Sendable`. + +## Wrap bare key paths outside builders with `Property` + +`KeyPath` no longer conforms to ``FormatProperty`` / ``ReadableProperty`` / +``WritableProperty`` directly. + +Inside a format builder, bare key-path syntax is unchanged — this still works exactly as +before: + +```swift +static var format: Format { + \.magic + \.count +} +``` + +You only need to change code that passed a key path where a ``FormatProperty`` value was +expected **outside** a builder context. Wrap it explicitly: + +```swift +// Before +let property = \MyType.field + +// After +let property = Property(\MyType.field) +``` + +## Replace `CannotWriteNilError` + +`CannotWriteNilError` has been removed; it was declared publicly but never thrown by the +library. If you referenced it by name, delete that reference. Nil handling on the write +side is unchanged: writing `nil` for an `Optional` field emits zero bytes. + +## Bonus: `skipChecksumVerification` now works + +This is a bug fix rather than a migration step, but worth knowing: in 0.1.x the +``EnvironmentValues/skipChecksumVerification`` flag was read but never acted upon, so +checksums were always verified. In 0.2.0 the flag correctly suppresses verification while +still consuming the checksum bytes. If you relied on the old (broken) behavior of "set the +flag but checksums verify anyway," remove the flag. diff --git a/Sources/DataKit/Property/Checksum.swift b/Sources/DataKit/Property/Checksum.swift index 35900dd..f43c218 100644 --- a/Sources/DataKit/Property/Checksum.swift +++ b/Sources/DataKit/Property/Checksum.swift @@ -23,7 +23,7 @@ import Foundation /// } /// CRC32.default // bare checksum expression covers the scope /// ``` -public struct ChecksumProperty: FormatProperty { +public struct ChecksumProperty: FormatProperty { // MARK: Nested Types @@ -44,12 +44,12 @@ public struct ChecksumProperty: Form /// the `ReadContext`. When `nil`, the value is verified and discarded. public init( _ checksum: ChecksumType, - at keyPath: KeyPath? = nil - ) where Format == ReadFormat, ChecksumType.Value: Readable { + at keyPath: KeyPath? = nil + ) where Format == ReadFormat, ChecksumType.Value: Readable & Sendable { self.format = ReadFormatBuilder.buildExpression( ReadFormat { container, context in let verificationData = container.consumedData - let value = try Value(from: &container) + let value = try ChecksumType.Value(from: &container) if !container.environment.skipChecksumVerification { try checksum.verify(value, for: verificationData) } @@ -65,12 +65,12 @@ public struct ChecksumProperty: Form /// optional-typed `keyPath`. public init( _ checksum: ChecksumType, - at keyPath: KeyPath? = nil - ) where Format == ReadFormat, ChecksumType.Value: Readable { + at keyPath: KeyPath? = nil + ) where Format == ReadFormat, ChecksumType.Value: Readable & Sendable { self.format = ReadFormatBuilder.buildExpression( ReadFormat { container, context in let verificationData = container.consumedData - let value = try Value(from: &container) + let value = try ChecksumType.Value(from: &container) if !container.environment.skipChecksumVerification { try checksum.verify(value, for: verificationData) } @@ -86,8 +86,8 @@ public struct ChecksumProperty: Form /// writes the value carried by `Root` directly. public init( _ checksum: ChecksumType, - at keyPath: KeyPath? = nil - ) where Format == WriteFormat, ChecksumType.Value: Writable { + at keyPath: KeyPath? = nil + ) where Format == WriteFormat, ChecksumType.Value: Writable & Sendable { self.format = WriteFormatBuilder.buildExpression( WriteFormat { container, root in let value = keyPath.map { root[keyPath: $0] } @@ -102,8 +102,8 @@ public struct ChecksumProperty: Form /// If the property is `nil`, the checksum is computed over the buffer instead. public init( _ checksum: ChecksumType, - at keyPath: KeyPath? = nil - ) where Format == WriteFormat, ChecksumType.Value: Writable { + at keyPath: KeyPath? = nil + ) where Format == WriteFormat, ChecksumType.Value: Writable & Sendable { self.format = WriteFormatBuilder.buildExpression( WriteFormat { container, root in let value = keyPath.flatMap { root[keyPath: $0] } @@ -117,13 +117,13 @@ public struct ChecksumProperty: Form /// Unified read/write of a checksum for a ``ReadWritable`` root. public init( _ checksum: ChecksumType, - at keyPath: KeyPath? = nil - ) where Format == ReadWriteFormat, ChecksumType.Value: ReadWritable { + at keyPath: KeyPath? = nil + ) where Format == ReadWriteFormat, ChecksumType.Value: ReadWritable & Sendable { self.format = ReadWriteFormatBuilder.buildExpression( ReadWriteFormat( read: .init { container, context in let verificationData = container.consumedData - let value = try Value(from: &container) + let value = try ChecksumType.Value(from: &container) if !container.environment.skipChecksumVerification { try checksum.verify(value, for: verificationData) } @@ -144,13 +144,13 @@ public struct ChecksumProperty: Form /// Unified read/write of a checksum for a ``ReadWritable`` root with an optional value path. public init( _ checksum: ChecksumType, - at keyPath: KeyPath? = nil - ) where Format == ReadWriteFormat, ChecksumType.Value: ReadWritable { + at keyPath: KeyPath? = nil + ) where Format == ReadWriteFormat, ChecksumType.Value: ReadWritable & Sendable { self.format = ReadWriteFormatBuilder.buildExpression( ReadWriteFormat( read: .init { container, context in let verificationData = container.consumedData - let value = try Value(from: &container) + let value = try ChecksumType.Value(from: &container) if !container.environment.skipChecksumVerification { try checksum.verify(value, for: verificationData) } @@ -169,3 +169,5 @@ public struct ChecksumProperty: Form } } + +extension ChecksumProperty: Sendable where Format: Sendable {} diff --git a/Sources/DataKit/Property/Convert.swift b/Sources/DataKit/Property/Convert.swift index 829ac11..2d38656 100644 --- a/Sources/DataKit/Property/Convert.swift +++ b/Sources/DataKit/Property/Convert.swift @@ -28,20 +28,18 @@ public struct Convert: FormatProperty { // MARK: Initialization /// Read-only conversion using a ``Conversion`` builder. - public init( + public init( _ keyPath: KeyPath, conversion makeConversion: Conversion.Make ) where Root: Readable, Format == ReadFormat { - self.init( - keyPath, - convert: Conversion.make(makeConversion).convert - ) + let conversion = Conversion.make(makeConversion) + self.init(keyPath) { try conversion.convert($0) } } /// Read-only conversion using a raw closure (wire type → in-memory type). - public init( + public init( _ keyPath: KeyPath, - convert: @escaping (ConvertedValue) throws -> Value + convert: @escaping @Sendable (ConvertedValue) throws -> Value ) where Root: Readable, Format == ReadFormat { self.format = ReadFormat { container, context in let value = try convert(ConvertedValue(from: &container)) @@ -50,20 +48,18 @@ public struct Convert: FormatProperty { } /// Write-only conversion using a ``Conversion`` builder. - public init( + public init( _ keyPath: KeyPath, conversion makeConversion: Conversion.Make ) where Root: Writable, Format == WriteFormat { - self.init( - keyPath, - convert: Conversion.make(makeConversion).convert - ) + let conversion = Conversion.make(makeConversion) + self.init(keyPath) { try conversion.convert($0) } } /// Write-only conversion using a raw closure (in-memory type → wire type). - public init( + public init( _ keyPath: KeyPath, - convert: @escaping (Value) throws -> ConvertedValue + convert: @escaping @Sendable (Value) throws -> ConvertedValue ) where Root: Writable, Format == WriteFormat { self.format = WriteFormat { container, root in try convert(root[keyPath: keyPath]).write(to: &container) @@ -71,12 +67,16 @@ public struct Convert: FormatProperty { } /// Reversible conversion for a ``ReadWritable`` root, using a ``ReversibleConversion`` builder. - public init( + public init( _ keyPath: KeyPath, conversion makeConversion: ReversibleConversion.Make ) where Root: ReadWritable, Format == ReadWriteFormat { let conversion = ReversibleConversion.make(makeConversion) - self.init(keyPath, reading: conversion.convert, writing: conversion.convert) + self.init( + keyPath, + reading: { try conversion.convert($0) }, + writing: { try conversion.convert($0) } + ) } /// Reversible conversion for a ``ReadWritable`` root, using paired raw closures. @@ -84,10 +84,10 @@ public struct Convert: FormatProperty { /// - Parameters: /// - reading: Maps wire type → in-memory type during decode. /// - writing: Maps in-memory type → wire type during encode. - public init( + public init( _ keyPath: KeyPath, - reading: @escaping (ConvertedValue) throws -> Value, - writing: @escaping (Value) throws -> ConvertedValue + reading: @escaping @Sendable (ConvertedValue) throws -> Value, + writing: @escaping @Sendable (Value) throws -> ConvertedValue ) where Root: ReadWritable, Format == ReadWriteFormat { self.format = ReadWriteFormat( read: .init { container, context in @@ -113,3 +113,5 @@ extension Convert: WritableProperty where Format: WritableProperty { try format.write(to: &container, using: root) } } + +extension Convert: Sendable where Format: Sendable {} diff --git a/Sources/DataKit/Property/Custom.swift b/Sources/DataKit/Property/Custom.swift index 075b877..34ff183 100644 --- a/Sources/DataKit/Property/Custom.swift +++ b/Sources/DataKit/Property/Custom.swift @@ -25,9 +25,9 @@ public struct Custom: FormatProperty { // MARK: Initialization /// Read-only custom logic. - public init( + public init( _ keyPath: KeyPath, - read: @escaping (inout ReadContainer) -> Value + read: @escaping @Sendable (inout ReadContainer) -> Value ) where Root: Readable, Format == ReadFormat { self.format = ReadFormat { container, context in try context.write(read(&container), for: keyPath) @@ -35,9 +35,9 @@ public struct Custom: FormatProperty { } /// Write-only custom logic. - public init( + public init( _ keyPath: KeyPath, - write: @escaping (inout WriteContainer, Value) throws -> Void + write: @escaping @Sendable (inout WriteContainer, Value) throws -> Void ) where Root: Writable, Format == WriteFormat { self.format = WriteFormat { container, root in try write(&container, root[keyPath: keyPath]) @@ -45,10 +45,10 @@ public struct Custom: FormatProperty { } /// Paired custom read and write for a ``ReadWritable`` root. - public init( + public init( _ keyPath: KeyPath, - read: @escaping (inout ReadContainer) -> Value, - write: @escaping (inout WriteContainer, Value) throws -> Void + read: @escaping @Sendable (inout ReadContainer) -> Value, + write: @escaping @Sendable (inout WriteContainer, Value) throws -> Void ) where Root: ReadWritable, Format == ReadWriteFormat { self.format = ReadWriteFormat( read: Custom(keyPath, read: read).format, @@ -69,3 +69,5 @@ extension Custom: WritableProperty where Format: WritableProperty { try format.write(to: &container, using: root) } } + +extension Custom: Sendable where Format: Sendable {} diff --git a/Sources/DataKit/Property/KeyPath.swift b/Sources/DataKit/Property/KeyPath.swift index e9f90ca..c6a872f 100644 --- a/Sources/DataKit/Property/KeyPath.swift +++ b/Sources/DataKit/Property/KeyPath.swift @@ -1,20 +1,21 @@ // KeyPath.swift +// +// `KeyPath` is logically Sendable — every key path is an immutable value-semantics-like +// reference to a property path. The Swift stdlib does not (yet) conform `KeyPath` to +// `Sendable` in the toolchain we target (5.10), so we add a retroactive `@unchecked` +// conformance here. The `@retroactive` annotation makes this explicit and silences the +// "extension declares a conformance of imported type" warning. +// +// Once the stdlib adds the conformance natively, this file can be removed. +extension AnyKeyPath: @retroactive @unchecked Sendable {} -import Foundation - -// Conformances that let a bare key path expression (e.g. `\.magic`) participate in format -// builders. These are equivalent to wrapping the key path in `Property(_:)`. - -extension KeyPath: FormatProperty {} - -extension KeyPath: ReadableProperty where Root: Readable, Value: Readable { - public func read(from container: inout ReadContainer, context: inout ReadContext) throws { - try context.write(Value(from: &container), for: self) - } -} - -extension KeyPath: WritableProperty where Root: Writable, Value: Writable { - public func write(to container: inout WriteContainer, using root: Root) throws { - try root[keyPath: self].write(to: &container) - } -} +// Bare key-path expressions (e.g. `\.magic`) inside a format builder are handled by the +// dedicated `buildExpression(_ expression: KeyPath)` and +// matching write/read-write overloads in `Builder/FormatBuilder+Read.swift` etc. They +// lift the key path into a `Property(_:)` automatically. +// +// We do not conform `KeyPath` itself to `FormatProperty` / `ReadableProperty` / +// `WritableProperty` because those would be retroactive conformances by protocols that +// now require `Sendable`, which trips Swift 6's "conformance must occur in the same +// source file" rule. If you need to pass a key path where a `FormatProperty` is expected +// outside a builder context, wrap it explicitly with `Property(\.field)`. diff --git a/Sources/DataKit/Property/OnRead.swift b/Sources/DataKit/Property/OnRead.swift index 7493f0c..4db7b8d 100644 --- a/Sources/DataKit/Property/OnRead.swift +++ b/Sources/DataKit/Property/OnRead.swift @@ -29,3 +29,5 @@ public struct OnRead: ReadableProperty, WritableProperty { public func write(to container: inout WriteContainer, using root: Root) throws {} } + +extension OnRead: Sendable {} diff --git a/Sources/DataKit/Property/OnWrite.swift b/Sources/DataKit/Property/OnWrite.swift index 1690e74..d163c2a 100644 --- a/Sources/DataKit/Property/OnWrite.swift +++ b/Sources/DataKit/Property/OnWrite.swift @@ -28,3 +28,5 @@ public struct OnWrite: ReadableProperty, WritableProperty { } } + +extension OnWrite: Sendable {} diff --git a/Sources/DataKit/Property/Property+Conversion.swift b/Sources/DataKit/Property/Property+Conversion.swift index 9799f52..6cb2d9b 100644 --- a/Sources/DataKit/Property/Property+Conversion.swift +++ b/Sources/DataKit/Property/Property+Conversion.swift @@ -2,7 +2,7 @@ import Foundation -extension Property where Root: Readable { +extension Property where Root: Readable, Value: Sendable { /// Fluent equivalent of `Convert(\.kp, conversion: ...)` for reading. public func conversion( @@ -13,14 +13,14 @@ extension Property where Root: Readable { /// Fluent equivalent of `Convert(\.kp, convert: ...)` for reading. public func converted( - _ convert: @escaping (ConvertedValue) throws -> Value + _ convert: @escaping @Sendable (ConvertedValue) throws -> Value ) -> Convert> { Convert(keyPath, convert: convert) } } -extension Property where Root: Writable { +extension Property where Root: Writable, Value: Sendable { /// Fluent equivalent of `Convert(\.kp, conversion: ...)` for writing. public func conversion( @@ -31,14 +31,14 @@ extension Property where Root: Writable { /// Fluent equivalent of `Convert(\.kp, convert: ...)` for writing. public func converted( - _ convert: @escaping (Value) throws -> ConvertedValue + _ convert: @escaping @Sendable (Value) throws -> ConvertedValue ) -> Convert> { Convert(keyPath, convert: convert) } } -extension Property where Root: ReadWritable { +extension Property where Root: ReadWritable, Value: Sendable { /// Fluent equivalent of `Convert(\.kp, conversion: ...)` for read+write. public func conversion( @@ -49,8 +49,8 @@ extension Property where Root: ReadWritable { /// Fluent equivalent of `Convert(\.kp, reading:writing:)`. public func converted( - reading: @escaping (ConvertedValue) throws -> Value, - writing: @escaping (Value) throws -> ConvertedValue + reading: @escaping @Sendable (ConvertedValue) throws -> Value, + writing: @escaping @Sendable (Value) throws -> ConvertedValue ) -> Convert> { Convert(keyPath, reading: reading, writing: writing) } diff --git a/Sources/DataKit/Property/Property+Custom.swift b/Sources/DataKit/Property/Property+Custom.swift index 467e2c9..7422ed1 100644 --- a/Sources/DataKit/Property/Property+Custom.swift +++ b/Sources/DataKit/Property/Property+Custom.swift @@ -2,34 +2,34 @@ import Foundation -extension Property where Root: Readable { +extension Property where Root: Readable, Value: Sendable { /// Fluent equivalent of `Custom(\.kp, read: ...)`. public func read( - _ read: @escaping (inout ReadContainer) -> Value + _ read: @escaping @Sendable (inout ReadContainer) -> Value ) -> Custom> { Custom(keyPath, read: read) } } -extension Property where Root: Writable { +extension Property where Root: Writable, Value: Sendable { /// Fluent equivalent of `Custom(\.kp, write: ...)`. public func write( - _ write: @escaping (inout WriteContainer, Value) throws -> Void + _ write: @escaping @Sendable (inout WriteContainer, Value) throws -> Void ) -> Custom> { Custom(keyPath, write: write) } } -extension Property where Root: ReadWritable { +extension Property where Root: ReadWritable, Value: Sendable { /// Fluent equivalent of `Custom(\.kp, read:write:)`. public func read( - _ read: @escaping (inout ReadContainer) -> Value, - write: @escaping (inout WriteContainer, Value) throws -> Void + _ read: @escaping @Sendable (inout ReadContainer) -> Value, + write: @escaping @Sendable (inout WriteContainer, Value) throws -> Void ) -> Custom> { Custom(keyPath, read: read, write: write) } diff --git a/Sources/DataKit/Property/Property.swift b/Sources/DataKit/Property/Property.swift index 45adf17..efe32da 100644 --- a/Sources/DataKit/Property/Property.swift +++ b/Sources/DataKit/Property/Property.swift @@ -36,3 +36,6 @@ extension Property: WritableProperty where Root: Writable, Value: Writable { try root[keyPath: keyPath].write(to: &container) } } + +extension Property: Sendable where Root: Sendable, Value: Sendable {} + diff --git a/Sources/DataKit/Property/Scope.swift b/Sources/DataKit/Property/Scope.swift index 6baef74..c4cca0e 100644 --- a/Sources/DataKit/Property/Scope.swift +++ b/Sources/DataKit/Property/Scope.swift @@ -88,3 +88,5 @@ extension Scope: WritableProperty where Format: WritableProperty { container.append(nestedContainer.data) } } + +extension Scope: Sendable where Format: Sendable {} diff --git a/Sources/DataKit/ReadWritable/Format.swift b/Sources/DataKit/ReadWritable/Format.swift index dc66312..6f14f1d 100644 --- a/Sources/DataKit/ReadWritable/Format.swift +++ b/Sources/DataKit/ReadWritable/Format.swift @@ -8,7 +8,7 @@ import Foundation /// ``Property``, ``Convert``, ``Custom``, ``Using``, ``Scope``, and ``Environment`` already /// conform. Conditional conformances to ``ReadableProperty`` and ``WritableProperty`` /// determine whether the value can be used in a read, write, or read-write context. -public protocol FormatProperty { +public protocol FormatProperty: Sendable { associatedtype Root } diff --git a/Sources/DataKit/ReadWritable/ReadWritable.swift b/Sources/DataKit/ReadWritable/ReadWritable.swift index 02a59c7..ca1ac0d 100644 --- a/Sources/DataKit/ReadWritable/ReadWritable.swift +++ b/Sources/DataKit/ReadWritable/ReadWritable.swift @@ -47,13 +47,19 @@ extension ReadWritable { public static var readFormat: ReadFormat { get throws { - try ReadFormat(read: format.read) + let format = try format + return ReadFormat { container, context in + try format.read(from: &container, context: &context) + } } } public static var writeFormat: WriteFormat { get throws { - try WriteFormat(write: format.write) + let format = try format + return WriteFormat { container, root in + try format.write(to: &container, using: root) + } } } diff --git a/Sources/DataKit/ReadWritable/ReadWritableProperty.swift b/Sources/DataKit/ReadWritable/ReadWritableProperty.swift index 54f9e86..a7f2e65 100644 --- a/Sources/DataKit/ReadWritable/ReadWritableProperty.swift +++ b/Sources/DataKit/ReadWritable/ReadWritableProperty.swift @@ -44,3 +44,5 @@ public struct ReadWriteFormat: FormatType, ReadableProperty, } +extension ReadWriteFormat: Sendable {} + diff --git a/Sources/DataKit/Readable/Readable.swift b/Sources/DataKit/Readable/Readable.swift index 4001ccd..99febf1 100644 --- a/Sources/DataKit/Readable/Readable.swift +++ b/Sources/DataKit/Readable/Readable.swift @@ -37,7 +37,7 @@ import Foundation /// /// If your type round-trips in both directions, prefer ``ReadWritable`` so you only declare /// the format once. -public protocol Readable { +public protocol Readable: Sendable { /// Builds the value from the populated context after the format walk has run. /// diff --git a/Sources/DataKit/Readable/ReadableProperty.swift b/Sources/DataKit/Readable/ReadableProperty.swift index 97b8a65..2bb49a0 100644 --- a/Sources/DataKit/Readable/ReadableProperty.swift +++ b/Sources/DataKit/Readable/ReadableProperty.swift @@ -3,7 +3,7 @@ import Foundation /// A ``FormatProperty`` that can decode bytes from a ``ReadContainer`` into a ``ReadContext``. -public protocol ReadableProperty: FormatProperty where Root: Readable { +public protocol ReadableProperty: FormatProperty where Root: Readable & Sendable { /// Parses values from `container` and stores them into `context`. /// @@ -21,12 +21,12 @@ public struct ReadFormat: ReadableProperty { // MARK: Stored Properties - private let _read: (inout ReadContainer, inout ReadContext) throws -> Void + private let _read: @Sendable (inout ReadContainer, inout ReadContext) throws -> Void // MARK: Initialization /// Wraps an imperative read closure as a `ReadFormat`. - public init(read: @escaping (inout ReadContainer, inout ReadContext) throws -> Void) { + public init(read: @escaping @Sendable (inout ReadContainer, inout ReadContext) throws -> Void) { self._read = read } @@ -50,3 +50,5 @@ extension ReadFormat: FormatType { } } +extension ReadFormat: Sendable {} + diff --git a/Sources/DataKit/Sendable.swift b/Sources/DataKit/Sendable.swift index 862fbe3..08ff427 100644 --- a/Sources/DataKit/Sendable.swift +++ b/Sources/DataKit/Sendable.swift @@ -1,23 +1,22 @@ // Sendable.swift // -// `Sendable` conformances for DataKit's value-type formats, conversions, containers, and -// environment. +// `Sendable` conformances for DataKit types that cannot be co-located with their +// declarations because of Swift 6's same-file rule. Conformances for value types that +// store `@Sendable` closures or pure-data fields live inline in their own source files. // -// Two design notes: +// The remaining `@unchecked Sendable` conformances below fall into two categories: // -// 1. Several types here are marked `@unchecked Sendable` rather than `Sendable`. The reason -// is almost always one of: (a) they store an `@escaping` closure whose generic -// parameters Swift cannot statically prove sendable; (b) they store a `KeyPath`, which -// only gained automatic `Sendable` inference in Swift 5.10 — DataKit's tools floor is -// 5.9. All such structs are immutable: their stored properties are `let`-bound and they -// have no mutating methods, so concurrent use is sound in practice. If you add a -// mutable stored property or a mutable method, revisit the conformance. +// 1. **Closure-bearing types whose stored closures are not yet `@Sendable`-annotated.** +// `Using`, `Environment`, `EnvironmentProperty`, `Conversion`, `ReversibleConversion`, +// and `DataBuilder.Component` carry closures that come from user call sites; making +// them `@Sendable` would propagate breaking changes through every caller of those +// types' closure-taking initializers. The structs are immutable, so concurrent use is +// sound in practice, but tightening this is a future major-version cleanup. // -// 2. Closures supplied by users at the call site (e.g. inside `Custom`, `Convert`, `Using`) -// are not marked `@Sendable` in the public initializers. That would be a source-breaking -// API change. Capturing non-`Sendable` mutable state from such a closure and then -// sending the resulting `FormatProperty` across actor boundaries is undefined behavior -// today; document this as a known limitation rather than fix it via API breakage. +// 2. **`Any`-storing types.** `ReadContainer`, `WriteContainer`, `ReadContext`, and +// `EnvironmentValues` carry untyped dictionaries (for environment propagation and for +// the keyed `ReadContext` scratchpad). These cannot be statically proven `Sendable`, +// but they have no mutating methods that race against concurrent use. import Foundation @@ -30,38 +29,18 @@ extension ReadContext: @unchecked Sendable {} // MARK: - Environment extension EnvironmentValues: @unchecked Sendable {} -// `Endianness: Sendable` and `Suffix: Sendable` are declared in their own source files -// because Swift 6 requires checked `Sendable` conformances to be co-located with the type. -// MARK: - Format types +// MARK: - Closure-bearing format primitives (future-major-version cleanup) -extension ReadFormat: @unchecked Sendable {} -extension WriteFormat: @unchecked Sendable {} -extension ReadWriteFormat: @unchecked Sendable {} - -// MARK: - Format-property primitives - -extension Property: @unchecked Sendable {} -extension Scope: @unchecked Sendable {} extension Using: @unchecked Sendable {} -extension Custom: @unchecked Sendable {} -extension Convert: @unchecked Sendable {} extension Environment: @unchecked Sendable {} extension EnvironmentProperty: @unchecked Sendable {} -extension OnRead: @unchecked Sendable {} -extension OnWrite: @unchecked Sendable {} -extension ChecksumProperty: @unchecked Sendable {} -// MARK: - Conversions +// MARK: - Conversions (future-major-version cleanup) extension Conversion: @unchecked Sendable {} extension ReversibleConversion: @unchecked Sendable {} -// MARK: - Sequence wrappers -// -// `PrefixCountArray` and `DynamicCountArray` declare their conditional `Sendable` -// conformance inline in their own source files (same-file rule for checked conformances). - -// MARK: - Result-builder accumulator +// MARK: - Result-builder accumulator (future-major-version cleanup) extension DataBuilder.Component: @unchecked Sendable {} diff --git a/Sources/DataKit/Values/ReadWritable+FloatingPoint.swift b/Sources/DataKit/Values/ReadWritable+FloatingPoint.swift index 35e334d..2335ad9 100644 --- a/Sources/DataKit/Values/ReadWritable+FloatingPoint.swift +++ b/Sources/DataKit/Values/ReadWritable+FloatingPoint.swift @@ -24,7 +24,7 @@ extension Float16: FixedWidthFloatingPoint, ReadWritable {} extension Float32: FixedWidthFloatingPoint, ReadWritable {} extension Float64: FixedWidthFloatingPoint, ReadWritable {} -extension FixedWidthFloatingPoint where Self: ReadWritable, BitPattern: ReadWritable { +extension FixedWidthFloatingPoint where Self: ReadWritable & Sendable, BitPattern: ReadWritable & Sendable { public init(from context: ReadContext) throws { try self.init(bitPattern: context.read(for: \.bitPattern)) diff --git a/Sources/DataKit/Values/ReadWritable+Integer.swift b/Sources/DataKit/Values/ReadWritable+Integer.swift index b372b62..c4f3736 100644 --- a/Sources/DataKit/Values/ReadWritable+Integer.swift +++ b/Sources/DataKit/Values/ReadWritable+Integer.swift @@ -21,7 +21,7 @@ extension UInt16: ReadWritable {} extension UInt32: ReadWritable {} extension UInt64: ReadWritable {} -extension FixedWidthInteger where Self: ReadWritable { +extension FixedWidthInteger where Self: ReadWritable & Sendable { public init(from context: ReadContext) throws { self = try context.read(for: \.self) diff --git a/Sources/DataKit/Values/ReadWritable+Optional.swift b/Sources/DataKit/Values/ReadWritable+Optional.swift index 48c3a60..6a24868 100644 --- a/Sources/DataKit/Values/ReadWritable+Optional.swift +++ b/Sources/DataKit/Values/ReadWritable+Optional.swift @@ -11,7 +11,7 @@ import Foundation /// - **Writing** a `nil` produces zero bytes; writing `.some` emits the wrapped value. This /// is useful for fields whose presence depends on an earlier flag. -extension Optional: Readable where Wrapped: Readable { +extension Optional: Readable where Wrapped: Readable & Sendable { public init(from context: ReadContext) throws { self = try context.read(for: \.self) @@ -25,7 +25,7 @@ extension Optional: Readable where Wrapped: Readable { } -extension Optional: Writable where Wrapped: Writable { +extension Optional: Writable where Wrapped: Writable & Sendable { public static var writeFormat: WriteFormat { WriteFormat { container, value in @@ -35,7 +35,7 @@ extension Optional: Writable where Wrapped: Writable { } -extension Optional: ReadWritable where Wrapped: ReadWritable { +extension Optional: ReadWritable where Wrapped: ReadWritable & Sendable { public static var format: Format { Format(read: readFormat, write: writeFormat) diff --git a/Sources/DataKit/Values/ReadWritable+Raw.swift b/Sources/DataKit/Values/ReadWritable+Raw.swift index d7d07e7..619f235 100644 --- a/Sources/DataKit/Values/ReadWritable+Raw.swift +++ b/Sources/DataKit/Values/ReadWritable+Raw.swift @@ -6,7 +6,7 @@ import Foundation // enums backed by integer raw values) automatically gain the corresponding conformance. // Unknown raw values during decode throw `ConversionError`. -extension RawRepresentable where Self: Readable, RawValue: Readable { +extension RawRepresentable where Self: Readable & Sendable, RawValue: Readable & Sendable { public init(from context: ReadContext) throws { let rawValue = try context.read(for: \.rawValue) @@ -23,7 +23,7 @@ extension RawRepresentable where Self: Readable, RawValue: Readable { } -extension RawRepresentable where Self: Writable, RawValue: Writable { +extension RawRepresentable where Self: Writable & Sendable, RawValue: Writable & Sendable { @WriteBuilder public static var writeFormat: WriteFormat { @@ -32,7 +32,7 @@ extension RawRepresentable where Self: Writable, RawValue: Writable { } -extension RawRepresentable where Self: ReadWritable, RawValue: ReadWritable { +extension RawRepresentable where Self: ReadWritable & Sendable, RawValue: ReadWritable & Sendable { @FormatBuilder public static var format: Format { diff --git a/Sources/DataKit/Writable/Writable.swift b/Sources/DataKit/Writable/Writable.swift index 1c911dd..5f55d99 100644 --- a/Sources/DataKit/Writable/Writable.swift +++ b/Sources/DataKit/Writable/Writable.swift @@ -24,7 +24,7 @@ import Foundation /// /// If your type round-trips in both directions, prefer ``ReadWritable`` so you only declare /// the format once. -public protocol Writable { +public protocol Writable: Sendable { /// The declarative description of how to serialize `Self` into bytes. /// diff --git a/Sources/DataKit/Writable/WritableProperty.swift b/Sources/DataKit/Writable/WritableProperty.swift index d7b87e2..e5ce580 100644 --- a/Sources/DataKit/Writable/WritableProperty.swift +++ b/Sources/DataKit/Writable/WritableProperty.swift @@ -3,7 +3,7 @@ import Foundation /// A ``FormatProperty`` that can encode a value of `Root` into a ``WriteContainer``. -public protocol WritableProperty: FormatProperty where Root: Writable { +public protocol WritableProperty: FormatProperty where Root: Writable & Sendable { /// Appends bytes derived from `root` to `container`. /// @@ -20,12 +20,12 @@ public struct WriteFormat: WritableProperty { // MARK: Stored Properties - private let _write: (inout WriteContainer, Root) throws -> Void + private let _write: @Sendable (inout WriteContainer, Root) throws -> Void // MARK: Initialization /// Wraps an imperative write closure as a `WriteFormat`. - public init(write: @escaping (inout WriteContainer, Root) throws -> Void) { + public init(write: @escaping @Sendable (inout WriteContainer, Root) throws -> Void) { self._write = write } @@ -50,3 +50,5 @@ extension WriteFormat: FormatType { } +extension WriteFormat: Sendable {} + From 4cfe7aa72192714bf916423b645509b414eacec4 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Fri, 29 May 2026 13:23:46 +0200 Subject: [PATCH 3/4] modernize --- README.md | 46 ++++++++++++++++++++++++--- Sources/DataKit/Property/Custom.swift | 13 +++++--- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5566dba..c9b6ca4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Swift Package Manager](https://img.shields.io/badge/SwiftPM-compatible-brightgreen.svg)](https://swift.org/package-manager) [![Swift](https://img.shields.io/badge/Swift-5.10%2B-orange.svg)](https://swift.org) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Documentation](https://swiftpackageindex.com/QuickBirdEng/DataKit/documentation)](https://swiftpackageindex.com/QuickBirdEng/DataKit/documentation) +[![Documentation](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FQuickBirdEng%2FDataKit%2Fbadge%3Ftype%3Ddocs)](https://swiftpackageindex.com/QuickBirdEng/DataKit/documentation) --- @@ -21,6 +21,7 @@ - [Concepts](#concepts) - [Built-in conformances](#built-in-conformances) - [Requirements](#requirements) +- [Architecture](#architecture) - [Contributing](#contributing) - [License](#license) @@ -62,7 +63,7 @@ let bytes = try header.write() // encode ``` That's everything: one `format` declaration drives both directions. Key paths in -`format` and `init(from:)` must match — see the [reading footgun](#-heads-up-keypath-mismatch-is-a-runtime-error) +`format` and `init(from:)` must match — see the [reading footgun](#heads-up-keypath-mismatch-is-a-runtime-error) below. ## Real-world example: weather-station packet @@ -139,7 +140,7 @@ let decoded = try WeatherStationUpdate(bytes) > If you only need one direction, conform to ``Readable`` or ``Writable`` and replace > `format` with `readFormat` (plus the `init(from:)`) or `writeFormat`. -### ⚠️ Heads-up: keypath mismatch is a runtime error +### Heads-up: keypath mismatch is a runtime error The format walk stores each parsed value into a `ReadContext` keyed by a `KeyPath`. Your `init(from:)` retrieves it by the same key path. A mismatch (typo, renamed @@ -203,9 +204,13 @@ custom logic appears more than once, lift it into a reusable `Conversion`. ```swift Custom(\.timestamp) { container in - let raw = try UInt32(from: &container) + // The read closure is non-throwing, so it cannot surface errors. `try!` is acceptable + // here only because a fixed 4-byte field is present by construction; for input that may + // be truncated or malformed, model the field as a `Conversion` instead. + let raw = try! UInt32(from: &container) return Date(timeIntervalSince1970: TimeInterval(raw)) } write: { container, date in + // The write closure *is* throwing, so errors propagate normally. try UInt32(date.timeIntervalSince1970).write(to: &container) } ``` @@ -313,6 +318,37 @@ format. Documentation is hosted at [Swift Package Index](https://swiftpackageindex.com/QuickBirdEng/DataKit/documentation). +## Architecture + +DataKit is organized around three protocols and a set of "property" types that compose +inside result builders. + +- **Core protocols** (`Sources/DataKit/`): `Readable` requires `init(from: ReadContext)` + and a `readFormat`; `Writable` requires a `writeFormat`; `ReadWritable` refines both with a + unified `format` from which `readFormat`/`writeFormat` are auto-derived. The "format" these + return is a tree of `FormatProperty` nodes assembled by a `@resultBuilder` + (`Builder/`). Each node knows how to read into a `ReadContainer` + `ReadContext` and/or + write into a `WriteContainer`. +- **Two-phase read model**: the format walk parses each value and stores it into a + `ReadContext` keyed by `KeyPath`; the initializer pulls values back out by the + *same* key paths. There is no compile-time link between the two — a mismatch throws at + runtime (see the [reading footgun](#heads-up-keypath-mismatch-is-a-runtime-error)). +- **Property building blocks** (`Property/`): `Property`, `Convert`, `Custom`, `Using`, + `Scope`, and `Environment` — see [Concepts](#concepts) above. +- **Environment system** (`Environment/`): a SwiftUI-style key-value store carried through the + containers. Built-in keys: `endianness` (host-default), `skipChecksumVerification`, `suffix`. + Add custom keys via `EnvironmentKey` + an `EnvironmentValues` extension. +- **Conversions** (`Conversions/`): reusable bidirectional `Conversion`/`ReversibleConversion` + values (`.encoded(.utf8)`, `.prefixCount(_:)`, `.dynamicCount`, `.cast(_:)`, etc.). +- **Checksums** (`Property/Checksum.swift`): built on the `Checksum` protocol from + [crc-swift](https://github.com/QuickBirdEng/crc-swift). Placing a checksum inside a `Scope` + makes it cover only that scope; checksum bytes are always written big-endian, independent of + the surrounding `endianness` setting. + +Endianness is host-default everywhere *except* checksums; set `.endianness(.big)` explicitly +when implementing portable wire protocols. The library target enables the `StrictConcurrency` +and `ExistentialAny` upcoming features, so new code should be `Sendable` where possible. + ## Changelog See [CHANGELOG.md](CHANGELOG.md) for release notes. Upgrading from 0.1.x? Read the @@ -324,7 +360,7 @@ Issues and PRs welcome. Before opening a PR: - Run `swift test` from the repo root — all existing tests should pass. - Add a round-trip test for any new format primitive or built-in conformance. -- See [`CLAUDE.md`](CLAUDE.md) for an architecture tour. +- See [Architecture](#architecture) above for a tour of how the pieces fit together. ## License diff --git a/Sources/DataKit/Property/Custom.swift b/Sources/DataKit/Property/Custom.swift index 34ff183..eba9d3e 100644 --- a/Sources/DataKit/Property/Custom.swift +++ b/Sources/DataKit/Property/Custom.swift @@ -5,10 +5,15 @@ import Foundation /// Drops down to raw `ReadContainer`/`WriteContainer` access for a single field. /// /// `Custom` is the escape hatch for fields that cannot be expressed with the existing -/// primitives or with a ``Convert`` + ``Conversion``. The `read` closure is not annotated -/// `throws` in its signature, but it is invoked inside a throwing context — call sites can -/// throw via `try` inside the closure body using `try!`/`try?` patterns or by lifting the -/// logic into a helper that throws. +/// primitives or with a ``Convert`` + ``Conversion``. +/// +/// - Important: The `read` closure is `(inout ReadContainer) -> Value` — it is *not* +/// throwing, so it cannot surface errors. Any failure inside it must be resolved locally: +/// `try!` traps the process and `try?` discards the error as `nil`; neither propagates out +/// of the format walk. If your read logic genuinely needs to fail (e.g. malformed input), +/// express it as a ``Conversion``/``ReversibleConversion`` whose throwing read is honored, +/// rather than as a read-only `Custom`. The `write` closure, by contrast, *is* `throws` and +/// propagates errors normally. /// /// If the same custom read/write logic appears more than once in your codebase, lift it /// into a reusable ``Conversion`` or ``ReversibleConversion`` instead. From de48fb4163233f97920ead4be4025063108eaf09 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Fri, 29 May 2026 15:08:50 +0200 Subject: [PATCH 4/4] update --- CHANGELOG.md | 15 +- .../DataKit/Builder/FormatBuilder+Read.swift | 5 +- .../DataKit.docc/Migrating-to-0.2.0.md | 24 ++- Sources/DataKit/Error.swift | 13 +- Sources/DataKit/Property/Checksum.swift | 143 ++++++++---------- Sources/DataKit/Property/Environment.swift | 10 +- .../Property/EnvironmentProperty.swift | 12 +- Sources/DataKit/Property/Using.swift | 10 +- Sources/DataKit/Readable/ReadContext.swift | 2 +- Sources/DataKit/Sendable.swift | 43 +++--- Tests/DataKitTests/Readme/ReadmeTests.swift | 9 ++ 11 files changed, 152 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 952b4d0..6feec9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,10 +46,11 @@ upgrade. ### Fixed -- **`skipChecksumVerification` is now honored.** `ChecksumProperty` previously read the - environment value but never consulted it, so checksums were always verified even when - verification had been disabled. The flag now correctly suppresses verification while - still consuming the checksum bytes. +- **`skipChecksumVerification` is now honored.** Reading the environment value but never + consulting it meant checksums were always verified even when verification had been + disabled. The flag now correctly suppresses verification (while still consuming the + checksum bytes) for both the explicit `ChecksumProperty` and the bare-checksum builder + form (`CRC32.default` inside a format block). - Corrected the broken `crc-swift` repository link in the README (`crc-swift.org` → `crc-swift`). @@ -69,8 +70,12 @@ fixes. - `FormatProperty` — and therefore `ReadableProperty` and `WritableProperty` — now refine `Sendable`; their `Root` must be `Sendable`. - Closures passed to `Custom` and `Convert` (and the fluent `Property.read`/`.write`/ - `.converted` helpers) must now be `@Sendable`. + `.converted` helpers), to `Using` and `Environment`, and to the `transformEnvironment` + modifiers must now be `@Sendable`. The value passed to `.environment(_:_:)` must be + `Sendable`. - `Checksum` types used inside a format must be `Sendable`. +- `UnexpectedValueError.expectedValue` and `.actualValue` are now typed `any Sendable` + instead of `Any`. - `KeyPath` no longer conforms to `FormatProperty` / `ReadableProperty` / `WritableProperty` directly. Bare key-path syntax (`\.field`) inside a format builder still works; passing a key path where a `FormatProperty` is expected **outside** a diff --git a/Sources/DataKit/Builder/FormatBuilder+Read.swift b/Sources/DataKit/Builder/FormatBuilder+Read.swift index 03902b4..ec74f80 100644 --- a/Sources/DataKit/Builder/FormatBuilder+Read.swift +++ b/Sources/DataKit/Builder/FormatBuilder+Read.swift @@ -26,7 +26,10 @@ extension FormatBuilder where Root: Readable, Format == ReadFormat { buildExpression( ReadFormat { container, _ in let verificationData = container.consumedData - try expression.verify(C.Value(from: &container), for: verificationData) + let value = try C.Value(from: &container) + if !container.environment.skipChecksumVerification { + try expression.verify(value, for: verificationData) + } } .endianness(.big) ) diff --git a/Sources/DataKit/DataKit.docc/Migrating-to-0.2.0.md b/Sources/DataKit/DataKit.docc/Migrating-to-0.2.0.md index 1b17534..3ea3f01 100644 --- a/Sources/DataKit/DataKit.docc/Migrating-to-0.2.0.md +++ b/Sources/DataKit/DataKit.docc/Migrating-to-0.2.0.md @@ -48,21 +48,30 @@ The same applies to custom `FormatProperty` / `ReadableProperty` / `WritableProp implementations: these protocols now refine `Sendable`, and their `Root` must be `Sendable`. -## Mark `Custom` and `Convert` closures `@Sendable` +## Mark format-building closures `@Sendable` Closures passed to ``Custom`` and ``Convert`` (and the fluent ``Property`` helpers -`read`, `write`, `converted`) are now `@Sendable`. In practice this means a closure may no -longer capture non-`Sendable` mutable state. Most format closures are pure transforms and -need no change: +`read`, `write`, `converted`), to ``Using`` and ``Environment``, and to the +`transformEnvironment` modifiers are now `@Sendable`. In practice this means a closure may +no longer capture non-`Sendable` mutable state. Most format closures are pure transforms +and need no change: ```swift -// Still compiles — pure transform, no captured mutable state +// Still compiles — pure transforms, no captured mutable state Convert(\.humidity) { Double($0) / 100 } writing: { UInt8($0 * 100) } +Using(\.features) { features in + if features.contains(.hasTemperature) { Convert(\.temperature) { $0.cast(Float.self) } } +} ``` If a closure captures something non-`Sendable`, refactor so the captured value is either `Sendable` or passed through the container/environment instead of captured. +Relatedly, the value passed to the `.environment(_:_:)` modifier must now be `Sendable` +(all built-in environment values already are), and the debugging payloads on +``UnexpectedValueError`` (`expectedValue`, `actualValue`) are now typed `any Sendable` +rather than `Any`. + ## Use `Checksum` types that are `Sendable` Checksum algorithms used inside a format must now be `Sendable`. The `CRC32`, `CRC16`, @@ -106,5 +115,6 @@ side is unchanged: writing `nil` for an `Optional` field emits zero bytes. This is a bug fix rather than a migration step, but worth knowing: in 0.1.x the ``EnvironmentValues/skipChecksumVerification`` flag was read but never acted upon, so checksums were always verified. In 0.2.0 the flag correctly suppresses verification while -still consuming the checksum bytes. If you relied on the old (broken) behavior of "set the -flag but checksums verify anyway," remove the flag. +still consuming the checksum bytes — for both the explicit ``ChecksumProperty`` and the +bare-checksum form (`CRC32.default` inside a format block). If you relied on the old +(broken) behavior of "set the flag but checksums verify anyway," remove the flag. diff --git a/Sources/DataKit/Error.swift b/Sources/DataKit/Error.swift index 073d0b2..ba2a0bd 100644 --- a/Sources/DataKit/Error.swift +++ b/Sources/DataKit/Error.swift @@ -8,6 +8,10 @@ import Foundation /// type itself can sit at the boundary between many different `Source`/`Target` pairs without /// being made generic. To inspect them programmatically, cast `source` to the expected /// source type and compare `targetType` against the expected target metatype. +/// +/// The `Sendable` conformance is `@unchecked` because `source` is type-erased from conversion +/// operators whose `Source` is not constrained to `Sendable`. This is sound: the struct is +/// immutable and exposes no way to mutate the boxed value, so there is nothing to race on. public struct ConversionError: Error, @unchecked Sendable { /// The value that could not be converted. @@ -21,12 +25,13 @@ public struct ConversionError: Error, @unchecked Sendable { /// /// Bare literal expressions inside a `ReadBuilder` (for example, `UInt8(0x02)` as a frame /// prefix) parse the corresponding bytes and assert equality with the literal. A mismatch -/// throws this error. -public struct UnexpectedValueError: Error, @unchecked Sendable { +/// throws this error. Both stored values are `Readable & Equatable` literals, which are +/// `Sendable`, so the values are type-erased to `any Sendable` rather than `Any`. +public struct UnexpectedValueError: Error, Sendable { /// The value declared in the format declaration. - public let expectedValue: Any + public let expectedValue: any Sendable /// The value actually decoded from the input data. - public let actualValue: Any + public let actualValue: any Sendable } diff --git a/Sources/DataKit/Property/Checksum.swift b/Sources/DataKit/Property/Checksum.swift index f43c218..9a91246 100644 --- a/Sources/DataKit/Property/Checksum.swift +++ b/Sources/DataKit/Property/Checksum.swift @@ -28,7 +28,6 @@ public struct ChecksumProperty? = nil ) where Format == ReadFormat, ChecksumType.Value: Readable & Sendable { - self.format = ReadFormatBuilder.buildExpression( - ReadFormat { container, context in - let verificationData = container.consumedData - let value = try ChecksumType.Value(from: &container) - if !container.environment.skipChecksumVerification { - try checksum.verify(value, for: verificationData) - } - if let keyPath { - try context.write(value, for: keyPath) - } - } - .endianness(.big) - ) + self.format = Self.makeReadFormat(checksum) { value, context in + if let keyPath { try context.write(value, for: keyPath) } + } } /// Reads and verifies a checksum, optionally exposing the decoded value at an @@ -67,19 +56,9 @@ public struct ChecksumProperty? = nil ) where Format == ReadFormat, ChecksumType.Value: Readable & Sendable { - self.format = ReadFormatBuilder.buildExpression( - ReadFormat { container, context in - let verificationData = container.consumedData - let value = try ChecksumType.Value(from: &container) - if !container.environment.skipChecksumVerification { - try checksum.verify(value, for: verificationData) - } - if let keyPath { - try context.write(value, for: keyPath) - } - } - .endianness(.big) - ) + self.format = Self.makeReadFormat(checksum) { value, context in + if let keyPath { try context.write(value, for: keyPath) } + } } /// Writes a checksum computed over the buffer so far, or — if `keyPath` is non-nil — @@ -88,14 +67,9 @@ public struct ChecksumProperty? = nil ) where Format == WriteFormat, ChecksumType.Value: Writable & Sendable { - self.format = WriteFormatBuilder.buildExpression( - WriteFormat { container, root in - let value = keyPath.map { root[keyPath: $0] } - ?? checksum.calculate(for: container.data) - try value.write(to: &container) - } - .endianness(.big) - ) + self.format = Self.makeWriteFormat(checksum) { root in + keyPath.map { root[keyPath: $0] } + } } /// Writes a checksum, sourcing the value from an optional-typed property on `Root`. @@ -104,14 +78,9 @@ public struct ChecksumProperty? = nil ) where Format == WriteFormat, ChecksumType.Value: Writable & Sendable { - self.format = WriteFormatBuilder.buildExpression( - WriteFormat { container, root in - let value = keyPath.flatMap { root[keyPath: $0] } - ?? checksum.calculate(for: container.data) - try value.write(to: &container) - } - .endianness(.big) - ) + self.format = Self.makeWriteFormat(checksum) { root in + keyPath.flatMap { root[keyPath: $0] } + } } /// Unified read/write of a checksum for a ``ReadWritable`` root. @@ -119,25 +88,13 @@ public struct ChecksumProperty? = nil ) where Format == ReadWriteFormat, ChecksumType.Value: ReadWritable & Sendable { - self.format = ReadWriteFormatBuilder.buildExpression( - ReadWriteFormat( - read: .init { container, context in - let verificationData = container.consumedData - let value = try ChecksumType.Value(from: &container) - if !container.environment.skipChecksumVerification { - try checksum.verify(value, for: verificationData) - } - if let keyPath { - try context.write(value, for: keyPath) - } - }, - write: .init { container, root in - let value = keyPath.map { root[keyPath: $0] } - ?? checksum.calculate(for: container.data) - try value.write(to: &container) - } - ) - .endianness(.big) + self.format = ReadWriteFormat( + read: Self.makeReadFormat(checksum) { value, context in + if let keyPath { try context.write(value, for: keyPath) } + }, + write: Self.makeWriteFormat(checksum) { root in + keyPath.map { root[keyPath: $0] } + } ) } @@ -146,24 +103,52 @@ public struct ChecksumProperty? = nil ) where Format == ReadWriteFormat, ChecksumType.Value: ReadWritable & Sendable { - self.format = ReadWriteFormatBuilder.buildExpression( - ReadWriteFormat( - read: .init { container, context in - let verificationData = container.consumedData - let value = try ChecksumType.Value(from: &container) - if !container.environment.skipChecksumVerification { - try checksum.verify(value, for: verificationData) - } - if let keyPath { - try context.write(value, for: keyPath) - } - }, - write: .init { container, root in - let value = keyPath.flatMap { root[keyPath: $0] } - ?? checksum.calculate(for: container.data) - try value.write(to: &container) + self.format = ReadWriteFormat( + read: Self.makeReadFormat(checksum) { value, context in + if let keyPath { try context.write(value, for: keyPath) } + }, + write: Self.makeWriteFormat(checksum) { root in + keyPath.flatMap { root[keyPath: $0] } + } + ) + } + + // MARK: Format Construction + + /// Builds the read side: reads the checksum bytes (big-endian), verifies them against the + /// bytes consumed so far unless ``EnvironmentValues/skipChecksumVerification`` is set, then + /// hands the decoded value to `store` (which decides whether to expose it at a key path). + /// + /// This is the single verification site shared by every read-capable initializer, so the + /// `skipChecksumVerification` semantics cannot drift between them. + private static func makeReadFormat( + _ checksum: ChecksumType, + store: @escaping @Sendable (ChecksumType.Value, inout ReadContext) throws -> Void + ) -> ReadFormat where ChecksumType.Value: Readable & Sendable { + ReadFormatBuilder.buildExpression( + ReadFormat { container, context in + let verificationData = container.consumedData + let value = try ChecksumType.Value(from: &container) + if !container.environment.skipChecksumVerification { + try checksum.verify(value, for: verificationData) } - ) + try store(value, &context) + } + .endianness(.big) + ) + } + + /// Builds the write side: writes the value produced by `source` (big-endian), falling back + /// to a checksum computed over the buffer accumulated so far when `source` returns `nil`. + private static func makeWriteFormat( + _ checksum: ChecksumType, + source: @escaping @Sendable (Root) -> ChecksumType.Value? + ) -> WriteFormat where ChecksumType.Value: Writable & Sendable { + WriteFormatBuilder.buildExpression( + WriteFormat { container, root in + let value = source(root) ?? checksum.calculate(for: container.data) + try value.write(to: &container) + } .endianness(.big) ) } diff --git a/Sources/DataKit/Property/Environment.swift b/Sources/DataKit/Property/Environment.swift index 0d36329..78eed15 100644 --- a/Sources/DataKit/Property/Environment.swift +++ b/Sources/DataKit/Property/Environment.swift @@ -21,14 +21,14 @@ public struct Environment: FormatProperty { // MARK: Stored Properties private let keyPath: KeyPath - private let format: (Value) throws -> Format + private let format: @Sendable (Value) throws -> Format // MARK: Initialization /// Read-only variant. public init ( _ keyPath: KeyPath, - @FormatBuilder format: @escaping (Value) throws -> Format + @FormatBuilder format: @escaping @Sendable (Value) throws -> Format ) where Format == ReadFormat { self.keyPath = keyPath self.format = format @@ -37,7 +37,7 @@ public struct Environment: FormatProperty { /// Write-only variant. public init ( _ keyPath: KeyPath, - @FormatBuilder format: @escaping (Value) throws -> Format + @FormatBuilder format: @escaping @Sendable (Value) throws -> Format ) where Format == WriteFormat { self.keyPath = keyPath self.format = format @@ -46,7 +46,7 @@ public struct Environment: FormatProperty { /// Read+write variant for a ``ReadWritable`` root. public init ( _ keyPath: KeyPath, - @FormatBuilder format: @escaping (Value) throws -> Format + @FormatBuilder format: @escaping @Sendable (Value) throws -> Format ) where Format == ReadWriteFormat { self.keyPath = keyPath self.format = format @@ -54,6 +54,8 @@ public struct Environment: FormatProperty { } +extension Environment: Sendable {} + extension Environment: ReadableProperty where Format: ReadableProperty { public func read(from container: inout ReadContainer, context: inout ReadContext) throws { let value = container.environment[keyPath: keyPath] diff --git a/Sources/DataKit/Property/EnvironmentProperty.swift b/Sources/DataKit/Property/EnvironmentProperty.swift index 92f9916..55506fa 100644 --- a/Sources/DataKit/Property/EnvironmentProperty.swift +++ b/Sources/DataKit/Property/EnvironmentProperty.swift @@ -11,7 +11,7 @@ extension FormatProperty { /// ```swift /// \.bigEndianField.environment(\.endianness, .big) /// ``` - public func environment( + public func environment( _ keyPath: WritableKeyPath, _ value: Value ) -> EnvironmentProperty { @@ -21,14 +21,14 @@ extension FormatProperty { /// Mutates a single environment value via a closure for the duration of `self`'s read/write. public func transformEnvironment( _ keyPath: WritableKeyPath, - transform: @escaping (inout Value) throws -> Void + transform: @escaping @Sendable (inout Value) throws -> Void ) -> EnvironmentProperty { EnvironmentProperty(self) { try transform(&$0[keyPath: keyPath]) } } /// Mutates the full environment via a closure for the duration of `self`'s read/write. public func transformEnvironment( - transform: @escaping (inout EnvironmentValues) throws -> Void + transform: @escaping @Sendable (inout EnvironmentValues) throws -> Void ) -> EnvironmentProperty { EnvironmentProperty(self) { try transform(&$0) } } @@ -51,13 +51,13 @@ public struct EnvironmentProperty: FormatProperty { // MARK: Stored Properties private let format: Format - private let transform: (inout EnvironmentValues) throws -> Void + private let transform: @Sendable (inout EnvironmentValues) throws -> Void // MARK: Initialization public init( _ format: Format, - transform: @escaping (inout EnvironmentValues) throws -> Void + transform: @escaping @Sendable (inout EnvironmentValues) throws -> Void ) { self.format = format self.transform = transform @@ -65,6 +65,8 @@ public struct EnvironmentProperty: FormatProperty { } +extension EnvironmentProperty: Sendable {} + extension EnvironmentProperty: ReadableProperty where Format: ReadableProperty { public func read(from container: inout ReadContainer, context: inout ReadContext) throws { let previousEnvironment = container.environment diff --git a/Sources/DataKit/Property/Using.swift b/Sources/DataKit/Property/Using.swift index 42456b1..18088b9 100644 --- a/Sources/DataKit/Property/Using.swift +++ b/Sources/DataKit/Property/Using.swift @@ -22,14 +22,14 @@ public struct Using: FormatProperty { // MARK: Stored Properties private let keyPath: KeyPath - private let format: (Value) throws -> Format + private let format: @Sendable (Value) throws -> Format // MARK: Initialization /// Read-only branching on a previously-read value. public init( _ keyPath: KeyPath, - @FormatBuilder with format: @escaping (Value) throws -> Format + @FormatBuilder with format: @escaping @Sendable (Value) throws -> Format ) where Format == ReadFormat { self.keyPath = keyPath self.format = format @@ -38,7 +38,7 @@ public struct Using: FormatProperty { /// Write-only branching on a value carried by `root`. public init( _ keyPath: KeyPath, - @FormatBuilder with format: @escaping (Value) throws -> Format + @FormatBuilder with format: @escaping @Sendable (Value) throws -> Format ) where Format == WriteFormat { self.keyPath = keyPath self.format = format @@ -47,7 +47,7 @@ public struct Using: FormatProperty { /// Read+write branching for a ``ReadWritable`` root. public init( _ keyPath: KeyPath, - @FormatBuilder with format: @escaping (Value) throws -> Format + @FormatBuilder with format: @escaping @Sendable (Value) throws -> Format ) where Format == ReadWriteFormat { self.keyPath = keyPath self.format = format @@ -55,6 +55,8 @@ public struct Using: FormatProperty { } +extension Using: Sendable {} + extension Using: ReadableProperty where Format: ReadableProperty { public func read(from container: inout ReadContainer, context: inout ReadContext) throws { let value = try context.read(for: keyPath) diff --git a/Sources/DataKit/Readable/ReadContext.swift b/Sources/DataKit/Readable/ReadContext.swift index 4f3f41b..33ddfde 100644 --- a/Sources/DataKit/Readable/ReadContext.swift +++ b/Sources/DataKit/Readable/ReadContext.swift @@ -25,7 +25,7 @@ public struct ReadContext { /// /// This usually indicates a mismatch between the key paths used in the format declaration /// and in `init(from:)`, or a conditional branch in the format that never executed. - public struct ValueDoesNotExistError: Error, @unchecked Sendable { + public struct ValueDoesNotExistError: Error, Sendable { /// The key path for which no value was found. public let keyPath: PartialKeyPath diff --git a/Sources/DataKit/Sendable.swift b/Sources/DataKit/Sendable.swift index 08ff427..6e7d528 100644 --- a/Sources/DataKit/Sendable.swift +++ b/Sources/DataKit/Sendable.swift @@ -1,46 +1,41 @@ // Sendable.swift // -// `Sendable` conformances for DataKit types that cannot be co-located with their -// declarations because of Swift 6's same-file rule. Conformances for value types that -// store `@Sendable` closures or pure-data fields live inline in their own source files. +// The `@unchecked Sendable` conformances below all share a single reason: each type stores a +// value the compiler cannot statically prove `Sendable`, yet the type is immutable after +// construction and has no method that mutates shared state, so concurrent use is sound. // -// The remaining `@unchecked Sendable` conformances below fall into two categories: +// 1. **`Any`-storing types.** `ReadContainer`, `WriteContainer`, `ReadContext`, and +// `EnvironmentValues` carry untyped dictionaries (for environment propagation and for +// the keyed `ReadContext` scratchpad). The boxed values cannot be proven `Sendable`. // -// 1. **Closure-bearing types whose stored closures are not yet `@Sendable`-annotated.** -// `Using`, `Environment`, `EnvironmentProperty`, `Conversion`, `ReversibleConversion`, -// and `DataBuilder.Component` carry closures that come from user call sites; making -// them `@Sendable` would propagate breaking changes through every caller of those -// types' closure-taking initializers. The structs are immutable, so concurrent use is -// sound in practice, but tightening this is a future major-version cleanup. +// 2. **Types storing closures over un-`Sendable`-constrained generics.** `Conversion`, +// `ReversibleConversion`, and `DataBuilder.Component` store transform closures over +// generic `Source`/`Target`/`Element` parameters that are not constrained to `Sendable`. +// Making the closures `@Sendable` would require propagating `Sendable` bounds through +// every conversion operator and builder expression — a wide breaking change deferred to a +// future major version. // -// 2. **`Any`-storing types.** `ReadContainer`, `WriteContainer`, `ReadContext`, and -// `EnvironmentValues` carry untyped dictionaries (for environment propagation and for -// the keyed `ReadContext` scratchpad). These cannot be statically proven `Sendable`, -// but they have no mutating methods that race against concurrent use. +// Conformances that *can* be checked (value types whose stored closures are `@Sendable` and +// whose other fields are `Sendable`) live inline in their own source files — see `Using`, +// `Environment`, `EnvironmentProperty`, `Convert`, `Custom`, `Scope`, etc. import Foundation -// MARK: - Containers +// MARK: - Containers (Any-storing) extension ReadContainer: @unchecked Sendable {} extension WriteContainer: @unchecked Sendable {} extension ReadContext: @unchecked Sendable {} -// MARK: - Environment +// MARK: - Environment (Any-storing) extension EnvironmentValues: @unchecked Sendable {} -// MARK: - Closure-bearing format primitives (future-major-version cleanup) - -extension Using: @unchecked Sendable {} -extension Environment: @unchecked Sendable {} -extension EnvironmentProperty: @unchecked Sendable {} - -// MARK: - Conversions (future-major-version cleanup) +// MARK: - Conversions (closures over un-Sendable-constrained generics) extension Conversion: @unchecked Sendable {} extension ReversibleConversion: @unchecked Sendable {} -// MARK: - Result-builder accumulator (future-major-version cleanup) +// MARK: - Result-builder accumulator (closure over un-Sendable-constrained generic) extension DataBuilder.Component: @unchecked Sendable {} diff --git a/Tests/DataKitTests/Readme/ReadmeTests.swift b/Tests/DataKitTests/Readme/ReadmeTests.swift index a9c51ce..79e35db 100644 --- a/Tests/DataKitTests/Readme/ReadmeTests.swift +++ b/Tests/DataKitTests/Readme/ReadmeTests.swift @@ -50,6 +50,15 @@ final class ReadmeTests: XCTestCase { XCTAssertThrowsError(try WeatherStationUpdate(wrongChecksumData)) { error in XCTAssert(error is VerificationError) } + + // `skipChecksumVerification` must bypass verification even for the bare + // `CRC32.default` form used by `WeatherStationUpdate.format`. + XCTAssertNoThrow { + let readValue = try WeatherStationUpdate(wrongChecksumData) { + $0.skipChecksumVerification = true + } + XCTAssertEqual(readValue, expectedValue) + } } }