diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..20ee545
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,40 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ macos:
+ name: macOS
+ runs-on: macos-14
+ 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.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/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..6feec9c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,99 @@
+# 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.** 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`).
+
+### 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), 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
+ 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/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..72a3788 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,9 +1,15 @@
-// swift-tools-version: 5.4
+// swift-tools-version: 5.10
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..c9b6ca4 100644
--- a/README.md
+++ b/README.md
@@ -1,138 +1,118 @@
+
-
+**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
+[](https://swift.org/package-manager)
+[](https://swift.org)
+[](LICENSE)
+[](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)
+- [Architecture](#architecture)
+- [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.2.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
-```
-
-### Reading data
+import DataKit
-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 +126,246 @@ 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
+ // 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)
}
-.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.
-## 🛠 Installation
+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.
-`DataKit` currently only supports Swift package manager.
+## Requirements
-#### Swift Package Manager
+- 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`)
-See [this WWDC presentation](https://developer.apple.com/videos/play/wwdc2019/408/) about more information how to adopt Swift packages in your app.
+Documentation is hosted at [Swift Package Index](https://swiftpackageindex.com/QuickBirdEng/DataKit/documentation).
-Specify `https://github.com/QuickBirdEng/DataKit.git` as the package link.
+## Architecture
-#### Manually
+DataKit is organized around three protocols and a set of "property" types that compose
+inside result builders.
-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.
+- **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.
-## 👤 Author
+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.
-DataKit is created with ❤️ by [QuickBird](https://quickbirdstudios.com).
+## 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
-## ❤️ 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 [Architecture](#architecture) above for a tour of how the pieces fit together.
-## 📃 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..ec74f80 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,24 +19,37 @@ extension FormatBuilder where Root: Readable, Format == ReadFormat {
}
}
- public static func buildExpression(_ expression: C) -> Format where C.Value: Readable {
+ /// 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
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)
)
}
+ /// 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``.
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..dabb6a4 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,13 +34,16 @@ extension FormatBuilder where Root: ReadWritable, Format == ReadWriteFormat(_ expression: C) -> Format where C.Value: ReadWritable {
+ /// A bare ``Checksum`` value verifies on read and computes on write, always in
+ /// big-endian.
+ public static func buildExpression(_ expression: C) -> Format where C.Value: ReadWritable {
.init(
read: ReadFormatBuilder.buildExpression(expression),
write: WriteFormatBuilder.buildExpression(expression)
)
}
+ /// A sequence of `ReadWritable & Equatable` literals is asserted/emitted in order.
public static func buildExpression(_ 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..0de8b5b 100644
--- a/Sources/DataKit/Builder/FormatBuilder+Write.swift
+++ b/Sources/DataKit/Builder/FormatBuilder+Write.swift
@@ -1,31 +1,37 @@
-//
-// 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)
+ .init { container, root in
+ try expression.write(to: &container, using: root)
+ }
}
+ /// 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))
}
- public static func buildExpression(_ expression: C) -> Format where C.Value: Writable {
+ /// 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
try expression.calculate(for: container.data)
@@ -35,6 +41,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..6b69ac8
--- /dev/null
+++ b/Sources/DataKit/DataKit.docc/DataKit.md
@@ -0,0 +1,144 @@
+# ``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
+
+### Release Notes
+
+-
+
+### 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/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..3ea3f01
--- /dev/null
+++ b/Sources/DataKit/DataKit.docc/Migrating-to-0.2.0.md
@@ -0,0 +1,120 @@
+# 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 format-building closures `@Sendable`
+
+Closures passed to ``Custom`` and ``Convert`` (and the fluent ``Property`` helpers
+`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 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`,
+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 — 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/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..ba2a0bd 100644
--- a/Sources/DataKit/Error.swift
+++ b/Sources/DataKit/Error.swift
@@ -1,20 +1,37 @@
-//
-// 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.
+///
+/// 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.
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. 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 Sendable
-public struct UnexpectedValueError: Error {
- public let expectedValue: Any
- public let actualValue: Any
+ /// The value actually decoded from the input data.
+ public let actualValue: any Sendable
}
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..9a91246 100644
--- a/Sources/DataKit/Property/Checksum.swift
+++ b/Sources/DataKit/Property/Checksum.swift
@@ -1,18 +1,33 @@
-//
-// File.swift
-//
-//
-// Created by Paul Kraft on 31.07.23.
-//
+// Checksum.swift
import Foundation
-public struct ChecksumProperty: FormatProperty {
+/// 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
public typealias Root = Format.Root
- public typealias Value = ChecksumType.Value
// MARK: Stored Properties
@@ -20,114 +35,124 @@ 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
- ) where Format == ReadFormat, ChecksumType.Value: Readable {
- self.format = ReadFormatBuilder.buildExpression(
- ReadFormat { container, context in
- let verificationData = container.consumedData
- let value = try Value(from: &container)
- try checksum.verify(value, for: verificationData)
- if let keyPath {
- try context.write(value, for: keyPath)
- }
- }
- .endianness(.big)
- )
+ at keyPath: KeyPath? = nil
+ ) where Format == ReadFormat, ChecksumType.Value: Readable & Sendable {
+ 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
+ /// optional-typed `keyPath`.
public init(
_ checksum: ChecksumType,
- at keyPath: KeyPath? = nil
- ) where Format == ReadFormat, ChecksumType.Value: Readable {
- self.format = ReadFormatBuilder.buildExpression(
- ReadFormat { container, context in
- let verificationData = container.consumedData
- let value = try Value(from: &container)
- try checksum.verify(value, for: verificationData)
- if let keyPath {
- try context.write(value, for: keyPath)
- }
- }
- .endianness(.big)
- )
+ at keyPath: KeyPath? = nil
+ ) where Format == ReadFormat, ChecksumType.Value: Readable & Sendable {
+ 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 —
+ /// writes the value carried by `Root` directly.
public init(
_ checksum: ChecksumType,
- at keyPath: KeyPath? = nil
- ) where Format == WriteFormat, ChecksumType.Value: Writable {
- 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)
- )
+ at keyPath: KeyPath? = nil
+ ) where Format == WriteFormat, ChecksumType.Value: Writable & Sendable {
+ 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`.
+ /// 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 {
- 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)
+ at keyPath: KeyPath? = nil
+ ) where Format == WriteFormat, ChecksumType.Value: Writable & Sendable {
+ self.format = Self.makeWriteFormat(checksum) { root in
+ keyPath.flatMap { root[keyPath: $0] }
+ }
+ }
+
+ /// Unified read/write of a checksum for a ``ReadWritable`` root.
+ public init(
+ _ checksum: ChecksumType,
+ at keyPath: KeyPath? = nil
+ ) where Format == ReadWriteFormat, ChecksumType.Value: ReadWritable & Sendable {
+ 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] }
}
- .endianness(.big)
)
}
+ /// 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 {
- self.format = ReadWriteFormatBuilder.buildExpression(
- ReadWriteFormat(
- read: .init { container, context in
- let verificationData = container.consumedData
- let value = try Value(from: &container)
+ at keyPath: KeyPath? = nil
+ ) where Format == ReadWriteFormat, ChecksumType.Value: ReadWritable & Sendable {
+ 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)
- 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)
}
- )
+ try store(value, &context)
+ }
.endianness(.big)
)
}
- public init(
+ /// 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,
- at keyPath: KeyPath? = nil
- ) where Format == ReadWriteFormat, ChecksumType.Value: ReadWritable {
- self.format = ReadWriteFormatBuilder.buildExpression(
- ReadWriteFormat(
- read: .init { container, context in
- let verificationData = container.consumedData
- let value = try Value(from: &container)
- 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)
- }
- )
+ 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)
)
}
}
+
+extension ChecksumProperty: Sendable where Format: Sendable {}
diff --git a/Sources/DataKit/Property/Convert.swift b/Sources/DataKit/Property/Convert.swift
index 525383e..2d38656 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,19 +27,19 @@ public struct Convert: FormatProperty {
// MARK: Initialization
- public init(
+ /// Read-only conversion using a ``Conversion`` builder.
+ 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) }
}
- public init(
+ /// Read-only conversion using a raw closure (wire type → in-memory type).
+ 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))
@@ -39,37 +47,47 @@ public struct Convert: FormatProperty {
}
}
- public init(
+ /// Write-only conversion using a ``Conversion`` builder.
+ 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) }
}
- public init(
+ /// Write-only conversion using a raw closure (in-memory type → wire type).
+ 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)
}
}
- public init(
+ /// Reversible conversion for a ``ReadWritable`` root, using a ``ReversibleConversion`` builder.
+ 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) }
+ )
}
- public init(
+ /// 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,
- 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
@@ -96,4 +114,4 @@ extension Convert: WritableProperty where Format: WritableProperty {
}
}
-
+extension Convert: Sendable where Format: Sendable {}
diff --git a/Sources/DataKit/Property/Custom.swift b/Sources/DataKit/Property/Custom.swift
index d83d0dd..eba9d3e 100644
--- a/Sources/DataKit/Property/Custom.swift
+++ b/Sources/DataKit/Property/Custom.swift
@@ -1,12 +1,22 @@
-//
-// 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``.
+///
+/// - 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.
public struct Custom: FormatProperty {
// MARK: Nested Types
@@ -19,28 +29,31 @@ public struct Custom: FormatProperty {
// MARK: Initialization
- public init(
+ /// Read-only custom logic.
+ 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)
}
}
- public init(
+ /// Write-only custom logic.
+ 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])
}
}
- public init(
+ /// Paired custom read and write for a ``ReadWritable`` root.
+ 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,
@@ -61,3 +74,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/Environment.swift b/Sources/DataKit/Property/Environment.swift
index bbf56a0..78eed15 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
@@ -16,29 +21,32 @@ 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
}
+ /// 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
}
+ /// 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
@@ -46,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 ab76937..55506fa 100644
--- a/Sources/DataKit/Property/EnvironmentProperty.swift
+++ b/Sources/DataKit/Property/EnvironmentProperty.swift
@@ -1,36 +1,47 @@
-//
-// File.swift
-//
-//
-// Created by Paul Kraft on 25.06.23.
-//
+// EnvironmentProperty.swift
import Foundation
extension FormatProperty {
- public func environment(
+ /// 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
) -> EnvironmentProperty {
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
+ 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