diff --git a/.github/workflows/publish-documentation.yml b/.github/workflows/publish-documentation.yml
index dbe1180..6047049 100644
--- a/.github/workflows/publish-documentation.yml
+++ b/.github/workflows/publish-documentation.yml
@@ -24,10 +24,12 @@ jobs:
# Must be set to this for deploying to GitHub Pages
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
- runs-on: macos-12
+ # Pack requires a Swift 6 toolchain, which is not available on older
+ # runner images.
+ runs-on: macos-15
steps:
- name: Checkout 🛎️
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Publish Documentation
run: |
xcodebuild docbuild -scheme Pack \
@@ -39,10 +41,10 @@ jobs:
--output-path docs;
echo "" > docs/index.html;
- name: Upload artifact
- uses: actions/upload-pages-artifact@v1
+ uses: actions/upload-pages-artifact@v3
with:
# Upload only docs directory
path: 'docs'
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v1
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index b496884..c622776 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
*.xcuserstate
*.resolved
.swiftpm
+.build
diff --git a/Package.swift b/Package.swift
index bbe6221..e9b2bfc 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,4 +1,4 @@
-// swift-tools-version: 5.7
+// swift-tools-version: 6.0
import PackageDescription
@@ -12,7 +12,7 @@ let package = Package(
.library(name: "Pack", targets: ["Pack"]),
],
targets: [
- .target(name: "Pack"),
- .testTarget(name: "PackTests", dependencies: ["Pack"]),
+ .target(name: "Pack", swiftSettings: [.swiftLanguageMode(.v6)]),
+ .testTarget(name: "PackTests", dependencies: ["Pack"], swiftSettings: [.swiftLanguageMode(.v6)]),
]
)
diff --git a/README.md b/README.md
index 9cd27bd..96dbd54 100644
--- a/README.md
+++ b/README.md
@@ -7,55 +7,43 @@
-Welcome to **Pack**, a Swift package to serialize and deserialize various data types into an external representation.
+Welcome to **Pack**, a Swift package to serialize and deserialize values into an external binary representation.
-Pack is similar in functionality to the built-in `Codable` protocols, however, unlike Codable, Pack is not key/value based, and as such is intend for packing and unpacking binary data for efficient storage.
+Pack is similar in purpose to the built-in `Codable` protocols, but unlike `Codable` it is not key/value based. The layout of the bytes is described by the code that writes them, and that layout *is* the format — which makes Pack suited to reading and writing binary file formats, and to storing data compactly.
+
+Every decision that affects the bytes on disk is made explicitly: the byte order, the width of every integer, and the encoding and framing of every string. None of it is inherited from the machine the code happens to be running on.
## Usage
### Basic packing and unpacking
-To serialize some data, a `Packer` is used to pack data into a Swift `Data` object. The `BinaryPack` object conforms to `Packer` and `Unpacker`, and encodes Swift primitive types.
-```swift
-// Initialize a new Packer
-let packer = BinaryPack()
+A `Packer` writes values into an external representation. `BinaryPacker` is the built-in packer for Swift's primitive types.
-// Pack an Integer
-try packer.pack(12345)
+```swift
+var packer = BinaryPacker(configuration: .littleEndian)
-// Pack a Double
+try packer.pack(Int64(12345))
try packer.pack(6789.0)
-
-// Pack a String with utf8 encoding
try packer.pack("Hello, world!", using: .utf8)
-```
-
-The data serialized by the Packer can be read as a Swift `Data` object.
-```swift
-let packedData = packer.data
+let data = packer.data
```
-Data can also be decoded using an `Unpacker`.
+Reading back uses a `BinaryUnpacker` with the same configuration.
```swift
-// Initialize a new Unpacker, and specify the data that should be unpacked
-let unpacker = BinaryPack(from: data)
-
-// Unpack an Integer
-let int = try unpacker.unpack(Int.self)
+var unpacker = BinaryUnpacker(from: data, configuration: .littleEndian)
-// Unpack an Double
+let int = try unpacker.unpack(Int64.self)
let double = try unpacker.unpack(Double.self)
-
-// Unpack an String that was packed with utf8 encoding
let string = try unpacker.unpack(String.self, using: .utf8)
```
+A configuration has no default byte order — you name one, or you do not get a packer. There is no process-wide state that could change how a file is written.
+
### Extending types to be Packable and Unpackable
-Any type can conform to `Packable`, allowing it to be serialized. The type can also conform to `Unpackable` allowing to be deserialized. A type that conforms to `Packed`, is shorthand for `Packable` and `Unpackable`, and must conform to both.
-For example, consider the following structure representing a named color.
+Any type can conform to `Packable` to describe how it is serialized, and `Unpackable` to describe how it is read back. `Packed` is shorthand for both.
```swift
struct Color {
@@ -65,30 +53,18 @@ struct Color {
var blue: Double
var alpha: Double = 1.0
}
-```
-
-To allow this struct to be serialized using a `Packer`, it must add conformance to `Packable`.
-```swift
-extension Color: Packable {
- func pack(to packer: inout Packer) throws {
- try packer.pack(name, using: .utf16)
+extension Color: Packed {
+ func pack(to packer: inout some Packer) throws {
+ try packer.pack(name, using: .utf16, framing: .lengthPrefixed(.uint32))
try packer.pack(red)
try packer.pack(green)
try packer.pack(blue)
try packer.pack(alpha)
}
-}
-```
-The `pack(to:)` functions provides a Packer as an _inout_ variable, and the function is expected to call functions on the Packer to serialize its member variables.
-
-To allow this struct to be deserialized using a `Unpacker`, it must add conformance to `Unpackable`.
-
-```swift
-extension Color: Unpackable {
- init(from unpacker: inout Unpacker) throws {
- self.name = try unpacker.unpack(String.self, using: .utf16)
+ init(from unpacker: inout some Unpacker) throws {
+ self.name = try unpacker.unpack(String.self, using: .utf16, framing: .lengthPrefixed(.uint32))
self.red = try unpacker.unpack(Double.self)
self.green = try unpacker.unpack(Double.self)
self.blue = try unpacker.unpack(Double.self)
@@ -97,41 +73,83 @@ extension Color: Unpackable {
}
```
-The `init(from:)` initializer provides an Unpacker as an inout variable, and the function is expected to call functions on the Packer to deserialize its member variables.
+The packer is a generic parameter rather than an existential, so packing a value compiles down to direct calls on the concrete packer instead of dispatching dynamically for every field. Where the packer's type genuinely cannot be known — dispatching through a class-bound existential, or calling a closure captured before any packer existed — wrap it in `AnyPacker`, which is a concrete type and so satisfies the generic parameter.
-### Reading and Writing from a Stream
-Pack provides support for serialization and deserialization of a stream, for example writing to memory, or directly to a file.
+### Framing
+
+A format usually needs to record how long something is before it has been written. `withLengthPrefix` reserves the length, packs the body, then fills the length in.
```swift
-if let outputSteam = OutputStream(toFileAtPath: myFilePath, append: false) {
- let packer = BinaryPack(writingTo: outputStream)
- try packer.pack("Hello, World!", using: .ascii)
+try packer.pack("MESH", using: .ascii, framing: .fixed(byteCount: 4))
+try packer.withLengthPrefix(.uint64) { packer in
+ try packer.pack(mesh)
}
```
-Data can also be deserialized from an `InputStream`.
+This produces the same bytes whatever the destination. A packer writing to memory patches the length in place; a packer writing to a stream, which cannot revise output it has already handed over, buffers the body until the length is known. Both paths run the same encoding code, so the file does not depend on how it was written.
+
+Reading back, `withLimit` bounds a span. Reads are checked against the limit as they happen, so a length that does not match its contents is reported rather than allowed to run on into whatever follows. Anything in the span that was not read is skipped, which is what lets an older reader accept a span that a newer writer has added fields to.
```swift
-if let inputStream = InputStream(fileAtPath: myFilePath) {
- let unpacker = BinaryPack(readingFrom: inputStream)
- try unpacker.unpack(String.self, using: .ascii)
+let length = try unpacker.unpack(UInt64.self)
+
+try unpacker.withLimit(byteCount: Int(length)) { unpacker in
+ // read the fields this version knows about; the rest is skipped
}
```
-### BinaryPack Options
-**BinaryPack** is the standard packer for serializing and deserializing Swift built-in types as binary data. By default, it writes the string byte size before the string data, ensuring it can easily be read back in.
+### Strings
-This behaviour can be modified using options when initializing the BinaryPack object.
+How a string records its extent is chosen per field, because one format routinely mixes several — a fixed-size tag, a length-prefixed name, a null-terminated path.
-For example, to read or write a null terminator after strings, the following options can be set.
+```swift
+try packer.pack(tag, using: .ascii, framing: .fixed(byteCount: 4))
+try packer.pack(name, using: .utf8, framing: .lengthPrefixed(.uint32))
+try packer.pack(path, using: .utf8, framing: .nullTerminated)
+```
+
+The configuration supplies a default so the common case stays short.
+
+### Collections
+
+Arrays of scalars pack in a single pass, and reduce to a bulk copy when no reordering is needed.
+
+```swift
+try packer.pack(contentsOf: vertices)
+try packer.pack(contentsOf: indices, countPrefixedBy: .uint32)
+```
+
+### Reading and writing streams
+
+Pack can serialize directly to an `OutputStream`, or read from an `InputStream`.
```swift
-let binaryPack = BinaryPack(options: [.stringsNullTerminated])
+if let outputStream = OutputStream(toFileAtPath: myFilePath, append: false) {
+ outputStream.open()
+ var packer = BinaryPacker(writingTo: outputStream, configuration: .littleEndian)
+ try packer.pack("Hello, World!", using: .ascii)
+}
```
+```swift
+if let inputStream = InputStream(fileAtPath: myFilePath) {
+ inputStream.open()
+ var unpacker = BinaryUnpacker(readingFrom: inputStream, configuration: .littleEndian)
+ let string = try unpacker.unpack(String.self, using: .ascii)
+}
+```
+
+Other destinations and sources — a memory mapped file, a socket, a compressing sink — are a four-method conformance to `PackDestination` or `UnpackSource`, and need no changes to the encoding.
+
## Limitations
-As Pack is intended for serializing and deserializing raw binary data, the layout of packed data is important. As such, beyond primitive built-in value types, such as `Int`, `Double`, `String`...etc, Pack provides no support for packing or unpacking complex Swift built-in types. As such, adding Pack protocol conformance to more complex built-in types is left to the end user, allowing them to ensure the data is written out in a stable format that matches the use case.
+Pack is intended for serializing raw binary data, so the layout of packed data matters. Beyond the primitive built-in types, Pack provides no automatic conformance for complex Swift types — adding it is left to the caller, so that the data is written in a stable format matching the use case.
+
+`Int` and `UInt` change width between platforms, so a file written with them is readable only on a machine of matching width — and would not fail on the machine it was tested on. Pack's integer APIs are constrained to `PortableInteger`, which `Int` and `UInt` deliberately do not conform to, so using one is a compile error rather than a corrupt file discovered later. Widen explicitly:
+
+```swift
+try packer.pack(Int64(value))
+```
## Installation
@@ -153,3 +171,5 @@ Import Pack wherever you’d like to use it:
```swift
import Pack
```
+
+Pack requires a Swift 6 toolchain, and builds in Swift 6 language mode.
diff --git a/Sources/Pack/AnyPacker.swift b/Sources/Pack/AnyPacker.swift
new file mode 100644
index 0000000..750d349
--- /dev/null
+++ b/Sources/Pack/AnyPacker.swift
@@ -0,0 +1,188 @@
+//
+// AnyPacker.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+
+/// A type-erased ``Packer``.
+///
+/// ``Packable`` takes its packer as a generic parameter, so that packing a
+/// value specialises rather than dispatching dynamically for every field. That
+/// leaves nowhere to put a packer whose concrete type is not known, because an
+/// existential does not conform to the protocol and cannot be passed `inout`.
+///
+/// AnyPacker is the way out. It is a concrete type that conforms to ``Packer``
+/// by forwarding to a packer it holds, so it satisfies the generic parameter
+/// while hiding what is underneath.
+///
+/// ```swift
+/// var packer = AnyPacker(BinaryPacker(configuration: .littleEndian))
+/// try somethingHoldingAClosure.pack(to: &packer)
+/// ```
+///
+/// Reach for this only where erasure is structurally required - dispatching
+/// through a class-bound existential, or invoking a closure captured at a point
+/// where no packer yet existed. It costs one dynamic dispatch per call, which
+/// is what an existential-based packer would have cost everywhere. Statically
+/// typed conformances should take `inout some Packer` and keep the
+/// specialisation.
+///
+/// - Note: The wrapped packer is held by reference internally, so copies of an
+/// AnyPacker share one underlying packer. Wrapping a value type packer such as
+/// ``MeasuringPacker`` copies it, and the original is not advanced by packing
+/// through the wrapper.
+///
+public struct AnyPacker: Packer {
+ private let box: AnyPackerBox
+
+/// Wrap a packer, hiding its concrete type.
+///
+/// - Parameters:
+/// - base: The packer to forward to.
+///
+ public init(_ base: consuming some Packer) {
+ self.box = PackerBox(base)
+ }
+
+/// Wrap a packer, hiding its concrete type.
+///
+/// Wrapping an AnyPacker returns it unchanged, rather than adding a second
+/// layer of forwarding.
+///
+/// - Parameters:
+/// - base: The packer to forward to.
+///
+ public init(_ base: AnyPacker) {
+ self.box = base.box
+ }
+
+ public var configuration: PackConfiguration {
+ box.configuration
+ }
+
+ public var userInfo: [PackUserInfoKey: any Sendable] {
+ get {
+ box.userInfo
+ }
+ nonmutating set {
+ box.userInfo = newValue
+ }
+ }
+
+ public var packedCount: Int {
+ box.packedCount
+ }
+
+ public var patchesInPlace: Bool {
+ box.patchesInPlace
+ }
+
+ public var hasOpenPlaceholders: Bool {
+ box.hasOpenPlaceholders
+ }
+
+ public func write(_ bytes: UnsafeRawBufferPointer) throws {
+ try box.write(bytes)
+ }
+
+ public func reserve(byteCount: Int) throws -> PackerPlaceholder {
+ try box.reserve(byteCount: byteCount)
+ }
+
+ public func fill(_ placeholder: PackerPlaceholder, with bytes: UnsafeRawBufferPointer) throws {
+ try box.fill(placeholder, with: bytes)
+ }
+}
+
+// The wrapped packer is held behind a class so that the requirements can be
+// forwarded through a vtable. This is why the protocol's requirements are
+// non-generic: a generic requirement could not be called on an existential, and
+// erasure would be impossible.
+//
+private class AnyPackerBox {
+ var configuration: PackConfiguration {
+ fatalError("abstract")
+ }
+
+ var userInfo: [PackUserInfoKey: any Sendable] {
+ get {
+ fatalError("abstract")
+ }
+ set {
+ fatalError("abstract")
+ }
+ }
+
+ var packedCount: Int {
+ fatalError("abstract")
+ }
+
+ var patchesInPlace: Bool {
+ fatalError("abstract")
+ }
+
+ var hasOpenPlaceholders: Bool {
+ fatalError("abstract")
+ }
+
+ func write(_ bytes: UnsafeRawBufferPointer) throws {
+ fatalError("abstract")
+ }
+
+ func reserve(byteCount: Int) throws -> PackerPlaceholder {
+ fatalError("abstract")
+ }
+
+ func fill(_ placeholder: PackerPlaceholder, with bytes: UnsafeRawBufferPointer) throws {
+ fatalError("abstract")
+ }
+}
+
+private final class PackerBox: AnyPackerBox {
+ private var base: Base
+
+ init(_ base: Base) {
+ self.base = base
+ }
+
+ override var configuration: PackConfiguration {
+ base.configuration
+ }
+
+ override var userInfo: [PackUserInfoKey: any Sendable] {
+ get {
+ base.userInfo
+ }
+ set {
+ base.userInfo = newValue
+ }
+ }
+
+ override var packedCount: Int {
+ base.packedCount
+ }
+
+ override var patchesInPlace: Bool {
+ base.patchesInPlace
+ }
+
+ override var hasOpenPlaceholders: Bool {
+ base.hasOpenPlaceholders
+ }
+
+ override func write(_ bytes: UnsafeRawBufferPointer) throws {
+ try base.write(bytes)
+ }
+
+ override func reserve(byteCount: Int) throws -> PackerPlaceholder {
+ try base.reserve(byteCount: byteCount)
+ }
+
+ override func fill(_ placeholder: PackerPlaceholder, with bytes: UnsafeRawBufferPointer) throws {
+ try base.fill(placeholder, with: bytes)
+ }
+}
diff --git a/Sources/Pack/AnyUnpacker.swift b/Sources/Pack/AnyUnpacker.swift
new file mode 100644
index 0000000..e06b614
--- /dev/null
+++ b/Sources/Pack/AnyUnpacker.swift
@@ -0,0 +1,164 @@
+//
+// AnyUnpacker.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+
+/// A type-erased ``Unpacker``.
+///
+/// The counterpart to ``AnyPacker``, for the same reasons. Reach for it only
+/// where the unpacker's concrete type genuinely cannot be known; statically
+/// typed conformances should take `inout some Unpacker`.
+///
+/// - Note: The wrapped unpacker is held by reference internally, so copies of
+/// an AnyUnpacker share one underlying unpacker.
+///
+public struct AnyUnpacker: Unpacker {
+ private let box: AnyUnpackerBox
+
+/// Wrap an unpacker, hiding its concrete type.
+///
+/// - Parameters:
+/// - base: The unpacker to forward to.
+///
+ public init(_ base: consuming some Unpacker) {
+ self.box = UnpackerBox(base)
+ }
+
+/// Wrap an unpacker, hiding its concrete type.
+///
+/// Wrapping an AnyUnpacker returns it unchanged, rather than adding a second
+/// layer of forwarding.
+///
+/// - Parameters:
+/// - base: The unpacker to forward to.
+///
+ public init(_ base: AnyUnpacker) {
+ self.box = base.box
+ }
+
+ public var configuration: PackConfiguration {
+ box.configuration
+ }
+
+ public var userInfo: [PackUserInfoKey: any Sendable] {
+ get {
+ box.userInfo
+ }
+ nonmutating set {
+ box.userInfo = newValue
+ }
+ }
+
+ public var unpackedCount: Int {
+ box.unpackedCount
+ }
+
+ public var remainingCount: Int? {
+ box.remainingCount
+ }
+
+ public func read(into buffer: UnsafeMutableRawBufferPointer) throws {
+ try box.read(into: buffer)
+ }
+
+ public func skip(_ byteCount: Int) throws {
+ try box.skip(byteCount)
+ }
+
+ public func pushLimit(byteCount: Int) throws {
+ try box.pushLimit(byteCount: byteCount)
+ }
+
+ public func popLimit(skippingRemainder: Bool) throws {
+ try box.popLimit(skippingRemainder: skippingRemainder)
+ }
+}
+
+private class AnyUnpackerBox {
+ var configuration: PackConfiguration {
+ fatalError("abstract")
+ }
+
+ var userInfo: [PackUserInfoKey: any Sendable] {
+ get {
+ fatalError("abstract")
+ }
+ set {
+ fatalError("abstract")
+ }
+ }
+
+ var unpackedCount: Int {
+ fatalError("abstract")
+ }
+
+ var remainingCount: Int? {
+ fatalError("abstract")
+ }
+
+ func read(into buffer: UnsafeMutableRawBufferPointer) throws {
+ fatalError("abstract")
+ }
+
+ func skip(_ byteCount: Int) throws {
+ fatalError("abstract")
+ }
+
+ func pushLimit(byteCount: Int) throws {
+ fatalError("abstract")
+ }
+
+ func popLimit(skippingRemainder: Bool) throws {
+ fatalError("abstract")
+ }
+}
+
+private final class UnpackerBox: AnyUnpackerBox {
+ private var base: Base
+
+ init(_ base: Base) {
+ self.base = base
+ }
+
+ override var configuration: PackConfiguration {
+ base.configuration
+ }
+
+ override var userInfo: [PackUserInfoKey: any Sendable] {
+ get {
+ base.userInfo
+ }
+ set {
+ base.userInfo = newValue
+ }
+ }
+
+ override var unpackedCount: Int {
+ base.unpackedCount
+ }
+
+ override var remainingCount: Int? {
+ base.remainingCount
+ }
+
+ override func read(into buffer: UnsafeMutableRawBufferPointer) throws {
+ try base.read(into: buffer)
+ }
+
+ override func skip(_ byteCount: Int) throws {
+ try base.skip(byteCount)
+ }
+
+ override func pushLimit(byteCount: Int) throws {
+ try base.pushLimit(byteCount: byteCount)
+ }
+
+ override func popLimit(skippingRemainder: Bool) throws {
+ try base.popLimit(skippingRemainder: skippingRemainder)
+ }
+}
diff --git a/Sources/Pack/BinaryPack.swift b/Sources/Pack/BinaryPack.swift
deleted file mode 100644
index fed01bb..0000000
--- a/Sources/Pack/BinaryPack.swift
+++ /dev/null
@@ -1,1054 +0,0 @@
-//
-// BinaryPack.swift
-// Pack
-//
-// Created by Matt Cox on 01/02/2022.
-// Copyright © 2024 Matt Cox. All rights reserved.
-//
-
-import Foundation
-
-/// A ``Packer`` and ``Unpacker`` that can pack and unpack binary data.
-///
-/// BinaryPack is intended for packing and unpacking primitive types as raw
-/// 8-bit memory aligned data. For example, an `Int8` and `Int16` can be
-/// packed alongside each other, with no marker indicating where one value ends
-/// and the other begins. It is up to the caller to describe a consistent data
-/// layout that allows values to be packed and unpacked.
-///
-public final class BinaryPack {
- private let state: State
- private var dataStored: Data? = nil
- private var streamStored: Stream? = nil
- private var offset: Int = 0
- private let byteOrder: ByteOrder
-
- private enum State {
- case packing
- case unpacking
- }
-
-/// The user info for storing local data associated with the current pack
-/// operation.
-///
- public var userInfo: [PackUserInfoKey: Any] = [:]
-
-/// The options used to initialize the BinaryPack object.
-///
- public let options: Options
-
-/// The default set of options that are used to initialize a BinaryPack
-/// object, when no options are specified.
-///
- public static var defaultOptions: Options = [.stringsPrependedWithSize]
-
-/// BinaryPack supports a number of options that controls how data is packed
-/// and unpacked.
-///
-/// The options are defined as an `OptionSet` so can be combined to specify
-/// multiple options.
-///
- public struct Options: OptionSet {
-/// The corresponding value of the raw type.
-///
-/// A new instance initialized with `rawValue` will be equivalent to
-/// this instance. For example:
-///
-/// ```swift
-/// (Options.stringsNullTerminated == Options(rawValue: Options.stringsNullTerminated.rawValue)!)
-/// // Prints "true"
-/// ```
-///
- public let rawValue: Int
-
-/// Null terminates strings by inserting and reading an empty value at
-/// the end of the string.
-///
- public static let stringsNullTerminated = Options(rawValue: 1 << 0)
-
-/// Packs and unpacks strings by storing the size of the string data as
-/// an `Int32`, immediately before the string.
-///
- public static let stringsPrependedWithSize = Options(rawValue: 1 << 1)
-
-/// Specifies no options.
-///
-/// - Warning: Packing string data with no options for how to handle
-/// string length could cause issues when unpacking, as the unpacker
-/// will not know when to stop reading the string.
-///
- public static var none = Options(rawValue: .zero)
-
-/// Initialize a new Options object from a raw value.
-///
-/// A new instance initialized with `rawValue` will be equivalent to
-/// this instance. For example:
-///
-/// ```swift
-/// (Options.stringsNullTerminated == Options(rawValue: Options.stringsNullTerminated.rawValue)!)
-/// // Prints "true"
-/// ```
-///
-/// - Parameters:
-/// - rawValue: The raw value for the options.
-///
- public init(rawValue: Int) {
- self.rawValue = rawValue
- }
- }
-
-/// Initialize a new BinaryPacker, writing to a `Data` object owned by the
-/// packer.
-///
-/// The packer is initialized using default options, and data can **only**
-/// be packed to this object - not unpacked.
-///
- public init() {
- self.state = .packing
- self.dataStored = Data()
- self.options = BinaryPack.defaultOptions
- self.byteOrder = .system
- }
-
-/// Initialize a new BinaryPacker, writing to a `Data` object owned by the
-/// packer.
-///
-/// Data can **only** be packed to this object - not unpacked.
-///
-/// - Parameters:
-/// - options: The options to initialize the BinaryPacker.
-/// - byteOrder: The byte order used to pack values.
-///
- public init(options: Options, byteOrder: ByteOrder = .system) {
- self.state = .packing
- self.dataStored = Data()
- self.options = options
- self.byteOrder = byteOrder
- }
-
-/// Initialize a new BinaryPacker, writing to a `Data` object owned by the
-/// packer.
-///
-/// The packer is initialized using default options, and data can **only**
-/// be packed to this object - not unpacked.
-///
-/// - Parameters:
-/// - byteOrder: The byte order used to pack values.
-///
- public init(byteOrder: ByteOrder) {
- self.state = .packing
- self.dataStored = Data()
- self.options = BinaryPack.defaultOptions
- self.byteOrder = byteOrder
- }
-
-/// Initialize a new BinaryPacker, writing to the provided `OutputStream`.
-///
-/// The packer is initialized using default options, and data can **only**
-/// be packed to this object - not unpacked.
-///
-/// - Parameters:
-/// - stream: The stream to pack the data into.
-///
- public init(writingTo stream: OutputStream) {
- self.state = .packing
- self.streamStored = stream
- self.options = BinaryPack.defaultOptions
- self.byteOrder = .system
- }
-
-/// Initialize a new BinaryPacker, writing to the provided `OutputStream`.
-///
-/// Data can **only** be packed to this object - not unpacked.
-///
-/// - Parameters:
-/// - stream: The stream to pack the data into.
-/// - options: The options to initialize the BinaryPacker.
-/// - byteOrder: The byte order used to pack values.
-///
- public init(writingTo stream: OutputStream, options: Options, byteOrder: ByteOrder = .system) {
- self.state = .packing
- self.streamStored = stream
- self.options = options
- self.byteOrder = byteOrder
- }
-
-/// Initialize a new BinaryPacker, writing to the provided `OutputStream`.
-///
-/// The packer is initialized using default options, and data can **only**
-/// be packed to this object - not unpacked.
-///
-/// - Parameters:
-/// - stream: The stream to pack the data into.
-/// - byteOrder: The byte order used to pack values.
-///
- public init(writingTo stream: OutputStream, byteOrder: ByteOrder) {
- self.state = .packing
- self.streamStored = stream
- self.options = BinaryPack.defaultOptions
- self.byteOrder = byteOrder
- }
-
-/// Initialize a new BinaryPacker, reading data from the provided `Data`
-/// object.
-///
-/// The unpacker is initialized using default options, and data can **only**
-/// be unpacked from this object - not packed.
-///
-/// - Parameters:
-/// - data: The `Data` object to unpack data from.
-///
- public init(from data: Data) {
- self.state = .unpacking
- self.dataStored = data
- self.options = BinaryPack.defaultOptions
- self.byteOrder = .system
- }
-
-/// Initialize a new BinaryPacker, reading data from the provided `Data`
-/// object.
-///
-/// Data can **only** be unpacked from this object - not packed.
-///
-/// - Parameters:
-/// - data: The `Data` object to unpack data from.
-/// - options: The options to initialize the BinaryPacker.
-/// - byteOrder: The byte order used to unpack values.
-///
- public init(from data: Data, options: Options, byteOrder: ByteOrder = .system) {
- self.state = .unpacking
- self.dataStored = data
- self.options = options
- self.byteOrder = byteOrder
- }
-
-/// Initialize a new BinaryPacker, reading data from the provided `Data`
-/// object.
-///
-/// The unpacker is initialized using default options, and data can **only**
-/// be unpacked from this object - not packed.
-///
-/// - Parameters:
-/// - data: The `Data` object to unpack data from.
-/// - byteOrder: The byte order used to unpack values.
-///
- public init(from data: Data, byteOrder: ByteOrder) {
- self.state = .unpacking
- self.dataStored = data
- self.options = BinaryPack.defaultOptions
- self.byteOrder = byteOrder
- }
-
-/// Initialize a new Packer, reading from the provided `InputStream`.
-///
-/// The unpacker is initialized using default options, and data can **only**
-/// be unpacked from this object - not packed.
-///
-/// - Parameters:
-/// - stream: The stream to unpack data from.
-///
- public init(readingFrom stream: InputStream) {
- self.state = .unpacking
- self.streamStored = stream
- self.options = BinaryPack.defaultOptions
- self.byteOrder = .system
- }
-
-/// Initialize a new Packer, reading from the provided `InputStream`.
-///
-/// Data can **only** be unpacked from this object - not packed.
-///
-/// - Parameters:
-/// - stream: The stream to unpack data from.
-/// - options: The options to initialize the BinaryPacker.
-/// - byteOrder: The byte order used to unpack values.
-///
- public init(readingFrom stream: InputStream, options: Options, byteOrder: ByteOrder = .system) {
- self.state = .unpacking
- self.streamStored = stream
- self.options = options
- self.byteOrder = byteOrder
- }
-
-/// Initialize a new Packer, reading from the provided `InputStream`.
-///
-/// The unpacker is initialized using default options, and data can **only**
-/// be unpacked from this object - not packed.
-///
-/// - Parameters:
-/// - stream: The stream to unpack data from.
-/// - options: The options to initialize the BinaryPacker.
-/// - byteOrder: The byte order used to unpack values.
-///
- public init(readingFrom stream: InputStream, byteOrder: ByteOrder) {
- self.state = .unpacking
- self.streamStored = stream
- self.options = BinaryPack.defaultOptions
- self.byteOrder = byteOrder
- }
-}
-
-extension BinaryPack: Packer {
-/// Indicates if the BinaryPacker is currently packing.
-///
- public var isPacking: Bool {
- get {
- self.state == .packing
- }
- }
-
- public var data: Data {
- get throws {
- guard self.isPacking else {
- throw PackError.notPacking
- }
-
- guard let data = dataStored else {
- throw PackError.invalidPackingDestination
- }
-
- return data
- }
- }
-
-/// Decodes the provided type into `Data`.
-///
-/// If the byte order used to initialize the BinaryPack is different to the
-/// system byte order, then the bytes in the `Data` object will be reversed.
-///
-/// - Parameters:
-/// - value: The value to decode.
-///
-/// - Returns: A `Data` object containing the bytes from the provided
-/// type.
-///
-/// - Throws: ``PackError`` if the type cannot be decoded.
-///
- private func decodeBytes(from value: T) throws -> Data {
- // The size of the object being packed in bytes.
- //
- let bytesCount = MemoryLayout.size
-
- // Shift the bits to grab each 8 bits of data. To prevent the UInt8 from
- // from going out of bounds when the larger bit shifted value is packed
- // into it, it is AND with a bit mask representing the smallest 8 bits.
- //
- var bitMask = T.zero
- for i in 0..<8 {
- bitMask |= 1 << i
- }
-
- var bytes = Data()
- for i in (0..> (i * 8)) & bitMask
- let byteData = Data(bytes: &byte, count: MemoryLayout.size)
- bytes.append(byteData)
- }
-
- if bytes.count != bytesCount {
- throw PackError.failure(reason: "Unable to decode bytes for writing")
- }
-
- // If the system byte order is different to the byte order used to
- // initialize the packer, then swap the bits.
- //
- ByteOrder.system.swap(&bytes, to: self.byteOrder)
-
- return bytes
- }
-
-/// Decodes the `Float` into a `Data` object.
-///
-/// If the byte order used to initialize the BinaryPack is different to the
-/// system byte order, then the bytes in the `Data` object will be reversed.
-///
-/// - Parameters:
-/// - value: The `Float` to decode.
-///
-/// - Returns: The `Data` object containing the bytes from the provided
-/// `Float`.
-///
-/// - Throws: ``PackError`` if the type cannot be decoded.
-///
- private func decodeBytes(from value: Float) throws -> Data {
- try decodeBytes(from: value.bitPattern)
- }
-
-/// Decodes the `Double` into a `Data` object.
-///
-/// If the byte order used to initialize the BinaryPack is different to the
-/// system byte order, then the bytes in the `Data` object will be reversed.
-///
-/// - Parameters:
-/// - value: The `Double` to decode.
-///
-/// - Returns: The `Data` object containing the bytes from the provided
-/// `Double`.
-///
-/// - Throws: ``PackError`` if the type cannot be decoded.
-///
- private func decodeBytes(from value: Double) throws -> Data {
- try decodeBytes(from: value.bitPattern)
- }
-
-/// Writes the `Data` object into the output - either a `Data` object, or an
-/// `OutputStream`.
-///
-/// - Parameters:
-/// - bytes: The `Data` object to write.
-///
-/// - Throws: `PackError` or a stream error if the data cannot be written.
-///
- private func writeBytes(_ bytes: Data) throws {
- guard self.isPacking else {
- throw PackError.notPacking
- }
-
- if bytes.isEmpty {
- return
- }
-
- // If the data object is not nil, then write the data there. Otherwise,
- // attempt to write it to the output stream.
- //
- if dataStored != nil {
- dataStored?.append(bytes)
- }
- else if let stream = streamStored as? OutputStream {
- let bytesWritten = try bytes.withUnsafeBytes { rawBufferPointer -> Int in
- let pointer = rawBufferPointer.bindMemory(to: UInt8.self)
- guard let baseAddress = pointer.baseAddress else {
- throw PackError.unknown
- }
- return stream.write(baseAddress, maxLength: bytes.count)
- }
-
- if bytesWritten == 0 {
- // If no bytes were written, then there is not enough space in
- // the buffer, so throw an error.
- //
- throw PackError.shortBuffer
- }
- else if bytesWritten == -1 {
- // If an error occurred, then throw the error.
- //
- if stream.streamStatus == .notOpen {
- throw PackError.failure(reason: "Stream is not open")
- }
- else if stream.streamStatus == .closed {
- throw PackError.failure(reason: "Stream has been closed")
- }
-
- let error = stream.streamError ?? PackError.unknown
- throw error
- }
- else if bytesWritten != bytes.count {
- // This shouldn't really happen, but if the bytes written is
- // different to the size of the data being written, then an
- // "unknown" error is thrown.
- //
- throw PackError.unknown
- }
- }
- else {
- // If neither the stream of the output data is valid, then throw
- // an error.
- //
- throw PackError.invalidPackingDestination
- }
- }
-
- public func pack(_ value: Bool) throws {
- // When packing a boolean, it's packed as a UInt8, with the first bit
- // set to 0 or 1, depending on the boolean state.
- //
- let bytes = Data(repeating: (value ? 1 : 0), count: 1)
- try writeBytes(bytes)
- }
-
- public func pack(_ value: Double) throws {
- let bytes = try decodeBytes(from: value)
- try writeBytes(bytes)
- }
-
- public func pack(_ value: Float) throws {
- let bytes = try decodeBytes(from: value)
- try writeBytes(bytes)
- }
-
- public func pack(_ value: Int) throws {
- let bytes = try decodeBytes(from: value)
- try writeBytes(bytes)
- }
-
- public func pack(_ value: Int8) throws {
- let bytes = try decodeBytes(from: value)
- try writeBytes(bytes)
- }
-
- public func pack(_ value: Int16) throws {
- let bytes = try decodeBytes(from: value)
- try writeBytes(bytes)
- }
-
- public func pack(_ value: Int32) throws {
- let bytes = try decodeBytes(from: value)
- try writeBytes(bytes)
- }
-
- public func pack(_ value: Int64) throws {
- let bytes = try decodeBytes(from: value)
- try writeBytes(bytes)
- }
-
- public func pack(_ value: UInt) throws {
- let bytes = try decodeBytes(from: value)
- try writeBytes(bytes)
- }
-
- public func pack(_ value: UInt8) throws {
- let bytes = Data(repeating: value, count: 1)
- try writeBytes(bytes)
- }
-
- public func pack(_ value: UInt16) throws {
- let bytes = try decodeBytes(from: value)
- try writeBytes(bytes)
- }
-
- public func pack(_ value: UInt32) throws {
- let bytes = try decodeBytes(from: value)
- try writeBytes(bytes)
- }
-
- public func pack(_ value: UInt64) throws {
- let bytes = try decodeBytes(from: value)
- try writeBytes(bytes)
- }
-
-/// Pack a `String` type value into the packer, with a specified encoding.
-///
-/// Regardless of the byte order specified when initializing the BinaryPack
-/// object, the byte order of the `String` will not be modified. If a
-/// specific byte order is required, that variant of the string encoding
-/// should be used, for example `utf16BigEndian` or `utf16LittleEndian`.
-///
-/// - Warning: Care should be taken when packing a `String`, to ensure the
-/// Encoding of the string will not change. For example, packing a string
-/// using `ascii` will store characters in 8 bits, whereas packing the
-/// string as `utf32` may result in up to 32 bits per character. To ensure
-/// packed strings can always be unpacked, a consistent encoding should be
-/// used.
-///
-/// - Parameters:
-/// - value: The `String` to pack.
-/// - encoding: The string encoding used to encode the characters in the
-/// string into memory.
-///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
-///
- public func pack(_ value: String, using encoding: String.Encoding = .utf8) throws {
- guard self.isPacking else {
- throw PackError.notPacking
- }
-
- // If the string is being null terminated, only a specific subset of
- // encodings are supported.
- //
- if options.contains(.stringsNullTerminated) {
- if encoding != .ascii && encoding != .utf8 {
- throw PackError.unsupportedEncoding
- }
- }
-
- // Get the string as data.
- //
- guard let stringData = value.data(using: encoding) else {
- throw PackError.failure(reason: "Unable to convert string into Data using specified encoding.")
- }
-
- // If the options specify the size of the string data should be written
- // first, then write it out.
- //
- let stringDataCount: Int32 = Int32(stringData.count)
- if self.options.contains(.stringsPrependedWithSize) {
- try self.pack(stringDataCount)
- }
-
- // Write the bytes into the output.
- //
- try writeBytes(stringData)
-
- // If the string should be null terminated, add the null terminators to
- // the end.
- //
- if self.options.contains(.stringsNullTerminated) {
- try self.pack(UInt8.zero)
- }
- }
-
-/// Pack bytes stored in a `Data` object into the packer.
-///
-/// Regardless of the byte order specified when initializing the BinaryPack
-/// object, the byte order of the `Data` will not be modified.
-///
-/// - Parameters:
-/// - value: The `Data` object containing the bytes to pack.
-///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
-///
- public func pack(_ data: Data) throws {
- try writeBytes(data)
- }
-
- public func pack(_ packable: Packable) throws {
- guard self.isPacking else {
- throw PackError.notPacking
- }
-
- var packer = self as Packer
- try packable.pack(to: &packer)
- }
-}
-
-extension BinaryPack: Unpacker {
-/// Indicates if the BinaryPacker is currently unpacking.
-///
- public var isUnpacking: Bool {
- get {
- self.state == .unpacking
- }
- }
-
-/// Encodes the bytes from the provided `Data` object into the specified
-/// type.
-///
-/// If the byte order used to initialize the BinaryPack is different to the
-/// system byte order, then the bytes in the `Data` object will be reversed.
-///
-/// - Parameters:
-/// - bytes: The bytes to encode into the type.
-/// - type: The type of object to encode the data into.
-///
-/// - Returns: A type initialized with the bytes from the `Data` object.
-///
-/// - Throws: ``PackError`` if the type cannot be encoded.
-///
- private func encodeBytes(_ bytes: Data, into type: T.Type) throws -> T {
- // If the system byte order is different to the byte order used to
- // initialize the unpacker, then swap the bits.
- //
- var data = bytes
- self.byteOrder.swap(&data, to: .system)
-
- // The size of the object being unpacked in bytes.
- //
- let bytesCount = MemoryLayout.size
- if bytesCount != bytes.count {
- throw PackError.failure(reason: "Unable to encode bytes for reading")
- }
-
- // Read each byte from the input data, shift left and OR with the
- // previous byte.
- //
- // This is less than ideal and can likely be optimized. Essentially
- // the type cannot be cast from a UInt8 to the target type, as the
- // value will be cast - not the bits. Therefore, we need to pack the
- // bits one by one from each byte into the bits of the target object.
- // TODO: Try and optimize away the nested loop with a conditional.
- //
- var value = T.zero
- var shift = bytesCount * 8
- for byteIndex in (0..> bitIndex) & 0x01) != 0x0 ? 0x01 : 0x0) << shift
- }
- }
-
- return value
- }
-
-/// Encodes the bytes from the provided `Data` object into a `Double`.
-///
-/// If the byte order used to initialize the BinaryPack is different to the
-/// system byte order, then the bytes in the `Data` object will be reversed
-/// before converting into a `Double`.
-///
-/// - Parameters:
-/// - bytes: The bytes to encode into a `Double`.
-/// - type: The type of object to encode the data into.
-///
-/// - Returns: A `Double` value initialized with the bytes from the `Data`
-/// object.
-///
-/// - Throws: ``PackError`` if the type cannot be encoded.
-///
- private func encodeBytes(_ bytes: Data, into: Double.Type) throws -> Double {
- let bitPattern = try encodeBytes(bytes, into: UInt64.self)
- return Double(bitPattern: bitPattern)
- }
-
-/// Encodes the bytes from the provided `Data` object into a `Float`.
-///
-/// If the byte order used to initialize the BinaryPack is different to the
-/// system byte order, then the bytes in the `Data` object will be reversed
-/// before converting into a `Float`.
-///
-/// - Parameters:
-/// - bytes: The bytes to encode into a `Float`.
-/// - type: The type of object to encode the data into.
-///
-/// - Returns: A `Float` value initialized with the bytes from the `Data`
-/// object.
-///
-/// - Throws: ``PackError`` if the type cannot be encoded.
-///
- private func encodeBytes(_ bytes: Data, into: Float.Type) throws -> Float {
- let bitPattern = try encodeBytes(bytes, into: UInt32.self)
- return Float(bitPattern: bitPattern)
- }
-
-/// Reads in the input for the specified number of bytes, and returns them
-/// as a `Data` object.
-///
-/// - Parameters:
-/// - count: The number of bytes to read.
-///
-/// - Returns: A `Data` object containing the specified number of bytes
-/// from the input source.
-///
-/// - Throws: `PackError` or a stream error if the data cannot be read.
-///
- private func readBytes(count: Int) throws -> Data {
- guard self.isUnpacking else {
- throw PackError.notUnpacking
- }
-
- var bytes = Data()
- if let data = dataStored {
- // Check if there is enough memory left in the data source to read
- // the provided object.
- //
- if data.count < (self.offset + count) {
- throw PackError.shortBuffer
- }
-
- // Read the data from the stored data source, offsetting the read
- // position forward at each step.
- //
- if count == 1 {
- bytes.append(data[self.offset])
- self.offset += 1
- }
- else {
- bytes.append(data.subdata(in: self.offset..<(self.offset + count)))
- self.offset += count
- }
- }
- else if let stream = streamStored as? InputStream {
- // Read the data from the input stream. As the length of the stream
- // is unknown, if this fails at any point, then we assume the end of
- // the stream has been reached.
- //
- if count == 1 {
- var value: UInt8 = 0
-
- let bytesRead = stream.read(&value, maxLength: count)
- if bytesRead == 0 {
- // If no bytes were read, then there is not enough space in
- // the buffer, so throw an error.
- //
- throw PackError.shortBuffer
- }
- else if bytesRead == -1 {
- // If an error occurred, then throw the error.
- //
- if stream.streamStatus == .notOpen {
- throw PackError.failure(reason: "Stream is not open")
- }
- else if stream.streamStatus == .closed {
- throw PackError.failure(reason: "Stream has been closed")
- }
-
- let error = stream.streamError ?? PackError.unknown
- throw error
- }
- else if bytesRead != count {
- // This shouldn't really happen, but if the bytes read is
- // different to the size of the data being read, then an
- // "unknown" error is thrown.
- //
- throw PackError.unknown
- }
-
- bytes.append(value)
- }
- else {
- var buffer = ContiguousArray(unsafeUninitializedCapacity: count) { pointer, initialized in
- for i in 0.. Int in
- return stream.read(pointer, maxLength: count)
- }
-
- if bytesRead == 0 {
- // If no bytes were read, then there is not enough space in
- // the buffer, so throw an error.
- //
- throw PackError.shortBuffer
- }
- else if bytesRead == -1 {
- // If an error occurred, then throw the error.
- //
- if stream.streamStatus == .notOpen {
- throw PackError.failure(reason: "Stream is not open")
- }
- else if stream.streamStatus == .closed {
- throw PackError.failure(reason: "Stream has been closed")
- }
-
- let error = stream.streamError ?? PackError.unknown
- throw error
- }
- else if bytesRead != count {
- // This shouldn't really happen, but if the bytes read is
- // different to the size of the data being read, then an
- // "unknown" error is thrown.
- //
- throw PackError.unknown
- }
-
- bytes.append(contentsOf: buffer)
- }
- }
- else {
- // If neither the stream of the input data is valid, then throw
- // an error.
- //
- throw PackError.invalidUnpackingSource
- }
-
- return bytes
- }
-
- public func offset(by count: UInt) throws {
- if dataStored != nil {
- self.offset += Int(count)
- }
- else if streamStored != nil {
- _ = try readBytes(count: Int(count))
- }
- else {
- throw PackError.invalidUnpackingSource
- }
- }
-
- public func unpack(_ type: Bool.Type) throws -> Bool {
- let bytes = try readBytes(count: MemoryLayout.size)
- guard let byte = bytes.first else {
- throw PackError.failure()
- }
- return byte != 0
- }
-
- public func unpack(_ type: Double.Type) throws -> Double {
- let bytes = try readBytes(count: MemoryLayout.size)
- return try encodeBytes(bytes, into: Double.self)
- }
-
- public func unpack(_ type: Float.Type) throws -> Float {
- let bytes = try readBytes(count: MemoryLayout.size)
- return try encodeBytes(bytes, into: Float.self)
- }
-
- public func unpack(_ type: Int.Type) throws -> Int {
- let bytes = try readBytes(count: MemoryLayout.size)
- return try encodeBytes(bytes, into: Int.self)
- }
-
- public func unpack(_ type: Int8.Type) throws -> Int8 {
- let bytes = try readBytes(count: MemoryLayout.size)
- return try encodeBytes(bytes, into: Int8.self)
- }
-
- public func unpack(_ type: Int16.Type) throws -> Int16 {
- let bytes = try readBytes(count: MemoryLayout.size)
- return try encodeBytes(bytes, into: Int16.self)
- }
-
- public func unpack(_ type: Int32.Type) throws -> Int32 {
- let bytes = try readBytes(count: MemoryLayout.size)
- return try encodeBytes(bytes, into: Int32.self)
- }
-
- public func unpack(_ type: Int64.Type) throws -> Int64 {
- let bytes = try readBytes(count: MemoryLayout.size)
- return try encodeBytes(bytes, into: Int64.self)
- }
-
- public func unpack(_ type: UInt.Type) throws -> UInt {
- let bytes = try readBytes(count: MemoryLayout.size)
- return try encodeBytes(bytes, into: UInt.self)
- }
-
- public func unpack(_ type: UInt8.Type) throws -> UInt8 {
- let bytes = try readBytes(count: MemoryLayout.size)
- return try encodeBytes(bytes, into: UInt8.self)
- }
-
- public func unpack(_ type: UInt16.Type) throws -> UInt16 {
- let bytes = try readBytes(count: MemoryLayout.size)
- return try encodeBytes(bytes, into: UInt16.self)
- }
-
- public func unpack(_ type: UInt32.Type) throws -> UInt32 {
- let bytes = try readBytes(count: MemoryLayout.size)
- return try encodeBytes(bytes, into: UInt32.self)
- }
-
- public func unpack(_ type: UInt64.Type) throws -> UInt64 {
- let bytes = try readBytes(count: MemoryLayout.size)
- return try encodeBytes(bytes, into: UInt64.self)
- }
-
-/// Unpack a `String` type value from the data source, packed with the
-/// specified encoding.
-///
-/// Regardless of the byte order specified when initializing the BinaryPack
-/// object, the byte order of the `String` will not be modified. If a
-/// specific byte order is required, that variant of the string encoding
-/// should be used, for example `utf16BigEndian` or `utf16LittleEndian`.
-///
-/// - Warning: Care should be taken when unpacking a `String`, to ensure the
-/// Encoding of the string is the same as when it was packed. For example,
-/// packing a string using `ascii` will store characters in 8 bits, whereas
-/// packing the string as `utf32` may result in up to 32 bits per character.
-/// To ensure packed strings can always be unpacked, a consistent encoding
-/// should be used.
-///
-/// - Parameters:
-/// - type: The type of object to unpack.
-/// - encoding: The string encoding that the string was encoded with when
-/// packing.
-///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
-///
- public func unpack(_ type: String.Type, using encoding: String.Encoding = .utf8) throws -> String {
- guard self.isUnpacking else {
- throw PackError.notUnpacking
- }
-
- // If the string is null terminated, only specific encodings are
- // supported.
- //
- if options.contains(.stringsNullTerminated) {
- if encoding != .ascii && encoding != .utf8 {
- throw PackError.unsupportedEncoding
- }
- }
-
- // If the options specify the size of the string data is written first,
- // then read it in.
- //
- var stringData = Data()
- if options.contains(.stringsPrependedWithSize) {
- let stringDataCount = try self.unpack(Int32.self)
- if stringDataCount > 0 {
- // Read the string for the specified number of bytes.
- //
- stringData = try readBytes(count: Int(stringDataCount))
-
- // If the string is null terminated, read the null terminator and
- // discard.
- //
- if options.contains(.stringsNullTerminated) {
- _ = try self.unpack(UInt8.self)
- }
- }
- }
- else if options.contains(.stringsNullTerminated) {
- // Continue reading until we reach a null terminator, or the end of
- // the input source is reached.
- //
- while(true) {
- // Read one byte at a time.
- //
- let data = try readBytes(count: 1)
- if let byte = data.first {
- if byte == .zero {
- break
- }
-
- stringData.append(byte)
- }
- else {
- throw PackError.unknown
- }
- }
- }
- else {
- // Continue reading until the end of the input source is reached.
- //
- do {
- while(true) {
- // Read one byte at a time.
- //
- let data = try readBytes(count: 1)
- if let byte = data.first {
- stringData.append(byte)
- }
- else {
- throw PackError.unknown
- }
- }
- }
- catch PackError.shortBuffer {
- // The end of the input source has been reached.
- }
- catch {
- throw error
- }
- }
-
- // Convert the string data into a string.
- //
- guard let string = String(data: stringData, encoding: encoding) else {
- throw PackError.failure()
- }
-
- return string
- }
-
-/// Unpack bytes from the data source, without interpreting the data as a
-/// specific type. The number of bytes to read can be specified.
-///
-/// Regardless of the byte order specified when initializing the BinaryPack
-/// object, the byte order of the `Data` will not be modified.
-///
-/// - Parameters:
-/// - type: The type of object to unpack.
-/// - size: The number of bytes to read from the data source.
-///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
-///
- public func unpack(_ type: Data.Type, size: Int) throws -> Data {
- try readBytes(count: size)
- }
-
- public func unpack(_ type: T.Type) throws -> T {
- guard self.isUnpacking else {
- throw PackError.notUnpacking
- }
-
- var unpacker = self as Unpacker
- return try T(from: &unpacker)
- }
-}
diff --git a/Sources/Pack/BinaryPacker.swift b/Sources/Pack/BinaryPacker.swift
new file mode 100644
index 0000000..f0c8bdd
--- /dev/null
+++ b/Sources/Pack/BinaryPacker.swift
@@ -0,0 +1,258 @@
+//
+// BinaryPacker.swift
+// Pack
+//
+// Created by Matt Cox on 01/02/2022.
+// Copyright © 2024 Matt Cox. All rights reserved.
+//
+
+import Foundation
+
+/// A ``Packer`` that packs values as raw binary data.
+///
+/// BinaryPacker writes primitive values as 8-bit aligned bytes with nothing
+/// between them - an `Int8` and an `Int16` packed in sequence carry no marker
+/// indicating where one ends and the next begins. It is up to the caller to
+/// describe a layout that can be read back.
+///
+/// ```swift
+/// var packer = BinaryPacker(configuration: .littleEndian)
+/// try packer.pack(Int64(12345))
+/// try packer.pack(6789.0)
+/// try packer.pack("Hello, world!")
+///
+/// let data = packer.data
+/// ```
+///
+/// - Note: The packing API is declared `mutating`, so that packers which are
+/// value types remain possible. A BinaryPacker is a reference type, but must
+/// still be held in a `var` for those methods to be callable.
+///
+/// Where a span must record its own length, use
+/// ``Packer/withLengthPrefix(_:_:)``. This works whatever the destination: a
+/// packer writing to memory patches the length in place, and a packer writing
+/// to a stream buffers the body until the length is known. Both produce the
+/// same bytes.
+///
+public final class BinaryPacker: Packer {
+/// The layout rules this packer applies.
+///
+ public let configuration: PackConfiguration
+
+/// The user info for storing local data associated with the current pack.
+///
+ public var userInfo: [PackUserInfoKey: any Sendable] = [:]
+
+/// The destination packed bytes are written to.
+///
+ public private(set) var destination: Destination
+
+/// How many bytes have been packed so far.
+///
+ public private(set) var packedCount: Int = 0
+
+ private let identity = PackerIdentity()
+ private var nextToken: UInt64 = 0
+ private var openTokens: Set = []
+ private var spans: [Span] = []
+
+// A span whose length is not yet known, buffered because the destination
+// cannot revise what it has already been given.
+//
+ private struct Span {
+ let token: UInt64
+ var value: [UInt8]?
+ var buffer: [UInt8]
+ }
+
+/// Initialize a new packer.
+///
+/// - Parameters:
+/// - destination: The destination to write packed bytes to.
+/// - configuration: The layout rules to apply.
+///
+ public init(destination: Destination, configuration: PackConfiguration) {
+ self.destination = destination
+ self.configuration = configuration
+ }
+
+ public var patchesInPlace: Bool {
+ destination.patchesInPlace
+ }
+
+/// Whether any reserved slot has yet to be filled.
+///
+/// Packed output is only complete once this is `false`. An unfilled slot
+/// left in a patchable destination reads as zeroes; an unfilled slot in a
+/// buffering destination means its span has not been written at all.
+///
+ public var hasOpenPlaceholders: Bool {
+ !openTokens.isEmpty
+ }
+
+ public func write(_ bytes: UnsafeRawBufferPointer) throws {
+ guard !bytes.isEmpty else {
+ return
+ }
+
+ try emit(bytes)
+ packedCount += bytes.count
+ }
+
+ public func reserve(byteCount: Int) throws -> PackerPlaceholder {
+ guard byteCount > 0 else {
+ throw PackError(
+ .valueOutOfRange,
+ offset: packedCount,
+ reason: "Cannot reserve \(byteCount) bytes"
+ )
+ }
+
+ let token = nextToken
+ nextToken += 1
+ openTokens.insert(token)
+
+ let offset = packedCount
+
+ if destination.patchesInPlace {
+ // The slot is written out as zeroes now and revised later, so
+ // nothing needs to be held in memory.
+ //
+ let placeholder = [UInt8](repeating: .zero, count: byteCount)
+ try placeholder.withUnsafeBytes { bytes in
+ try emit(bytes)
+ }
+ }
+ else {
+ // The destination has already handed on everything written so
+ // far, so the body of this span is buffered until its length is
+ // known. Nesting is handled by stacking buffers.
+ //
+ spans.append(Span(token: token, value: nil, buffer: []))
+ }
+
+ packedCount += byteCount
+
+ return PackerPlaceholder(owner: identity, token: token, offset: offset, byteCount: byteCount)
+ }
+
+ public func fill(_ placeholder: PackerPlaceholder, with bytes: UnsafeRawBufferPointer) throws {
+ guard placeholder.isOwned(by: identity) else {
+ throw PackError(
+ .invalidPlaceholder,
+ offset: placeholder.offset,
+ reason: "The placeholder was not created by this packer"
+ )
+ }
+
+ guard bytes.count == placeholder.byteCount else {
+ throw PackError(
+ .valueOutOfRange,
+ offset: placeholder.offset,
+ reason: "\(bytes.count) bytes cannot fill a \(placeholder.byteCount) byte placeholder"
+ )
+ }
+
+ guard openTokens.contains(placeholder.token) else {
+ throw PackError(
+ .placeholderAlreadyFilled,
+ offset: placeholder.offset,
+ reason: "The placeholder has already been filled"
+ )
+ }
+
+ if destination.patchesInPlace {
+ try destination.patch(at: placeholder.offset, with: bytes)
+ openTokens.remove(placeholder.token)
+ return
+ }
+
+ // The slot cannot be revised once written, so the value is recorded
+ // against its span and the span is held until everything preceding it
+ // is resolved. Spans may be filled in any order, exactly as they may
+ // be on a destination that patches - only how long the bytes are held
+ // in memory differs.
+ //
+ guard let index = spans.firstIndex(where: { $0.token == placeholder.token }) else {
+ throw PackError(
+ .invalidPlaceholder,
+ offset: placeholder.offset,
+ reason: "The placeholder does not correspond to an open span"
+ )
+ }
+
+ spans[index].value = Array(bytes)
+ openTokens.remove(placeholder.token)
+
+ try flushResolvedSpans()
+ }
+
+// Writes out every span at the bottom of the stack whose slot is now known.
+//
+// A span's bytes sit at a fixed position, so the outermost unresolved span
+// blocks everything after it. Once its value arrives, it and any spans behind
+// it that are already resolved can be handed to the destination in order.
+//
+ private func flushResolvedSpans() throws {
+ while let span = spans.first, let value = span.value {
+ spans.removeFirst()
+
+ try value.withUnsafeBytes { bytes in
+ try destination.write(bytes)
+ }
+
+ try span.buffer.withUnsafeBytes { bytes in
+ try destination.write(bytes)
+ }
+ }
+ }
+
+// Writes bytes to the innermost open span, or to the destination when there
+// is none. This does not advance `packedCount`, so that flushing a buffered
+// span does not count its bytes a second time.
+//
+ private func emit(_ bytes: UnsafeRawBufferPointer) throws {
+ guard !bytes.isEmpty else {
+ return
+ }
+
+ if spans.isEmpty {
+ try destination.write(bytes)
+ }
+ else {
+ spans[spans.count - 1].buffer.append(contentsOf: bytes)
+ }
+ }
+}
+
+extension BinaryPacker where Destination == DataDestination {
+/// Initialize a new packer, accumulating packed bytes in memory.
+///
+/// - Parameters:
+/// - configuration: The layout rules to apply.
+///
+ public convenience init(configuration: PackConfiguration) {
+ self.init(destination: DataDestination(), configuration: configuration)
+ }
+
+/// The packed bytes, including any the destination was seeded with.
+///
+/// - Warning: Any slot reserved and not yet filled reads as zeroes. Call
+/// ``Packer/finish()`` before using this, which reports that as an error.
+///
+ public var data: Data {
+ destination.data
+ }
+}
+
+extension BinaryPacker where Destination == OutputStreamDestination {
+/// Initialize a new packer, writing packed bytes to a stream.
+///
+/// - Parameters:
+/// - stream: The stream to write to. The stream must already be open.
+/// - configuration: The layout rules to apply.
+///
+ public convenience init(writingTo stream: OutputStream, configuration: PackConfiguration) {
+ self.init(destination: OutputStreamDestination(stream), configuration: configuration)
+ }
+}
diff --git a/Sources/Pack/BinaryUnpacker.swift b/Sources/Pack/BinaryUnpacker.swift
new file mode 100644
index 0000000..787a172
--- /dev/null
+++ b/Sources/Pack/BinaryUnpacker.swift
@@ -0,0 +1,191 @@
+//
+// BinaryUnpacker.swift
+// Pack
+//
+// Created by Matt Cox on 01/02/2022.
+// Copyright © 2024 Matt Cox. All rights reserved.
+//
+
+import Foundation
+
+/// An ``Unpacker`` that unpacks values from raw binary data.
+///
+/// ```swift
+/// var unpacker = BinaryUnpacker(from: data, configuration: .littleEndian)
+/// let count = try unpacker.unpack(Int64.self)
+/// let scale = try unpacker.unpack(Double.self)
+/// let name = try unpacker.unpack(String.self)
+/// ```
+///
+/// Where the input is framed into spans, ``Unpacker/withLimit(byteCount:_:)``
+/// bounds reads to the current span, so that a length which does not match its
+/// contents is reported rather than allowed to run on into what follows.
+///
+public final class BinaryUnpacker: Unpacker {
+/// The layout rules this unpacker applies.
+///
+ public let configuration: PackConfiguration
+
+/// The user info for storing local data associated with the current unpack.
+///
+ public var userInfo: [PackUserInfoKey: any Sendable] = [:]
+
+/// The source unpacked bytes are read from.
+///
+ public private(set) var source: Source
+
+// Absolute offsets at which the innermost and enclosing spans end.
+//
+ private var limits: [Int] = []
+
+/// Initialize a new unpacker.
+///
+/// - Parameters:
+/// - source: The source to read bytes from.
+/// - configuration: The layout rules to apply.
+///
+ public init(source: Source, configuration: PackConfiguration) {
+ self.source = source
+ self.configuration = configuration
+ }
+
+ public var unpackedCount: Int {
+ source.consumedCount
+ }
+
+ public var remainingCount: Int? {
+ guard let limit = limits.last else {
+ return source.remainingCount
+ }
+
+ let withinSpan = limit - unpackedCount
+
+ guard let withinSource = source.remainingCount else {
+ return withinSpan
+ }
+
+ return Swift.min(withinSpan, withinSource)
+ }
+
+/// How many spans are currently bounded by a limit.
+///
+ public var limitDepth: Int {
+ limits.count
+ }
+
+ public func read(into buffer: UnsafeMutableRawBufferPointer) throws {
+ guard !buffer.isEmpty else {
+ return
+ }
+
+ try check(byteCount: buffer.count)
+ try source.read(into: buffer)
+ }
+
+ public func skip(_ byteCount: Int) throws {
+ guard byteCount > 0 else {
+ return
+ }
+
+ try check(byteCount: byteCount)
+ try source.skip(byteCount)
+ }
+
+ public func pushLimit(byteCount: Int) throws {
+ guard byteCount >= 0 else {
+ throw PackError(
+ .valueOutOfRange,
+ offset: unpackedCount,
+ reason: "A span cannot be \(byteCount) bytes long"
+ )
+ }
+
+ // A span is checked against its surroundings when it opens, rather
+ // than leaving a length that could never be valid to fail somewhere
+ // less obvious later on.
+ //
+ try check(byteCount: byteCount)
+
+ if let remaining = source.remainingCount, byteCount > remaining {
+ throw PackError(
+ .endOfInput,
+ offset: unpackedCount,
+ reason: "A span of \(byteCount) bytes extends beyond the \(remaining) bytes remaining"
+ )
+ }
+
+ limits.append(unpackedCount + byteCount)
+ }
+
+ public func popLimit(skippingRemainder: Bool) throws {
+ guard let limit = limits.popLast() else {
+ throw PackError(
+ .unsupportedOperation,
+ offset: unpackedCount,
+ reason: "There is no span to close"
+ )
+ }
+
+ guard skippingRemainder else {
+ return
+ }
+
+ let remainder = limit - unpackedCount
+
+ guard remainder >= 0 else {
+ throw PackError(
+ .overrun,
+ offset: unpackedCount,
+ reason: "The span's contents extended \(-remainder) bytes beyond its declared length"
+ )
+ }
+
+ // The limit has already been removed, so this skips against whatever
+ // span encloses it - which the limit was checked to sit within.
+ //
+ if remainder > 0 {
+ try source.skip(remainder)
+ }
+ }
+
+// Checks that reading the given number of bytes stays within the innermost
+// span, where there is one.
+//
+ private func check(byteCount: Int) throws {
+ guard let limit = limits.last else {
+ return
+ }
+
+ guard unpackedCount + byteCount <= limit else {
+ throw PackError(
+ .overrun,
+ offset: unpackedCount,
+ reason: "Reading \(byteCount) bytes would pass the end of the span, \(limit - unpackedCount) bytes away"
+ )
+ }
+ }
+}
+
+extension BinaryUnpacker where Source == DataSource {
+/// Initialize a new unpacker, reading from bytes held in memory.
+///
+/// - Parameters:
+/// - data: The bytes to unpack from.
+/// - configuration: The layout rules to apply.
+///
+ public convenience init(from data: Data, configuration: PackConfiguration) {
+ self.init(source: DataSource(data), configuration: configuration)
+ }
+}
+
+extension BinaryUnpacker where Source == InputStreamSource {
+/// Initialize a new unpacker, reading from a stream.
+///
+/// - Parameters:
+/// - stream: The stream to read from. The stream must already be open.
+/// - configuration: The layout rules to apply.
+///
+ public convenience init(readingFrom stream: InputStream, configuration: PackConfiguration) {
+ self.init(source: InputStreamSource(stream), configuration: configuration)
+ }
+}
diff --git a/Sources/Pack/ByteOrder.swift b/Sources/Pack/ByteOrder.swift
index 256679b..3dcdd76 100644
--- a/Sources/Pack/ByteOrder.swift
+++ b/Sources/Pack/ByteOrder.swift
@@ -6,9 +6,6 @@
// Copyright © 2024 Matt Cox. All rights reserved.
//
-import CoreFoundation
-import Foundation
-
/// Describes the order or sequence of bytes of data in memory.
///
/// A big-endian system stores the most significant byte at the smallest memory
@@ -18,13 +15,7 @@ import Foundation
/// The vast majority of modern computing platforms use little-endian, however
/// some file formats continue to use big-endian encoding.
///
-public enum ByteOrder {
-/// An unknown byte order.
-///
-/// This byte order is unsupported, and is used for handling errors only.
-///
- case unknown
-
+public enum ByteOrder: Hashable, Sendable {
/// A little-endian byte order.
///
/// Little-endian stores least significant byte at the smallest address, and
@@ -33,88 +24,25 @@ public enum ByteOrder {
/// - Note: This byte order is most common on modern computing platforms.
///
case littleEndian
-
+
/// A big-endian byte order.
///
/// Big-endian stores most significant byte at the smallest address, and the
/// least significant byte at the largest address.
///
case bigEndian
-
-/// The byte order used by the system this module has been compiled for.
-///
- public static var system: ByteOrder = ByteOrder(from: CFByteOrderGetCurrent())
-
-/// Returns the byte order as a `CFByteOrder` Core Foundation type.
-///
- public var byteOrder: CFByteOrder {
- switch self {
- case .unknown:
- return CFByteOrder(CFByteOrderUnknown.rawValue)
- case .littleEndian:
- return CFByteOrder(CFByteOrderLittleEndian.rawValue)
- case .bigEndian:
- return CFByteOrder(CFByteOrderBigEndian.rawValue)
- }
- }
-
-/// Initialise a new `ByteOrder` object from a `CFByteOrder` Core Foundation
-/// type.
-///
-/// - Parameters:
-/// - byteOrder: The `CFByteOrder` describing the endianness of the new
-/// `ByteOrder`.
-///
- public init(from byteOrder: CFByteOrder) {
- if byteOrder == CFByteOrderUnknown.rawValue {
- assertionFailure("Unknown CFByteOrder")
- self = .unknown
- }
- else if byteOrder == CFByteOrderLittleEndian.rawValue {
- self = .littleEndian
- }
- else if byteOrder == CFByteOrderBigEndian.rawValue {
- self = .bigEndian
- }
- else {
- // This is here incase CFByteOrder is extended in future to
- // support other byte orders.
- //
- preconditionFailure("Unsupported CFByteOrder")
- }
- }
-/// Swap the byte order of some data from one byte order to another.
-///
-/// - Parameters:
-/// - data: The `Data` object whose bytes should be swapped.
-/// - byteOrder: The `ByteOrder` that the bytes will be swapped into.
-///
-/// - Returns: A boolean indicating if the byte order of the memory was
-/// modified.
-///
- @discardableResult
- public func swap(_ data: inout Data, to byteOrder: ByteOrder) -> Bool {
- ByteOrder.swap(&data, from: self, to: byteOrder)
- }
-
-/// Swap the byte order of some data from one byte order to another.
-///
-/// - Parameters:
-/// - data: The `Data` object whose bytes should be swapped.
-/// - fromByteOrder: The `ByteOrder` that the bytes are currently in.
-/// - toByteOrder: The `ByteOrder` that the bytes will be swapped into.
-///
-/// - Returns: A boolean indicating if the byte order of the memory was
-/// modified.
+/// The byte order used by the system this module has been compiled for.
///
- @discardableResult
- static public func swap(_ data: inout Data, from fromByteOrder: ByteOrder, to toByteOrder: ByteOrder) -> Bool {
- if fromByteOrder == toByteOrder || fromByteOrder == .unknown || toByteOrder == .unknown {
- return false
- }
-
- data.reverse()
- return true
- }
+/// - Warning: This is provided for inspection, not for configuring a packer.
+/// Packing with the host byte order produces a file whose layout depends on
+/// the machine that wrote it. Name an endianness explicitly instead.
+///
+ public static let system: ByteOrder = {
+#if _endian(big)
+ return .bigEndian
+#else
+ return .littleEndian
+#endif
+ }()
}
diff --git a/Sources/Pack/IO/DataDestination.swift b/Sources/Pack/IO/DataDestination.swift
new file mode 100644
index 0000000..0f8d124
--- /dev/null
+++ b/Sources/Pack/IO/DataDestination.swift
@@ -0,0 +1,86 @@
+//
+// DataDestination.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+
+/// A ``PackDestination`` that accumulates packed bytes in memory.
+///
+/// This destination can revise bytes already written, so a packer using it
+/// fills reserved slots directly and never buffers.
+///
+public struct DataDestination: PackDestination {
+/// The bytes held by this destination, including any it was seeded with.
+///
+ public private(set) var data: Data
+
+// Where this destination's own output begins. A packer counts from zero and
+// its placeholder offsets are relative to itself, so anything the destination
+// was seeded with has to be added back before touching `data`.
+//
+ private let baseOffset: Int
+
+/// Initialize a new destination.
+///
+/// - Parameters:
+/// - data: Bytes already written, which packed bytes are appended after.
+/// Defaults to empty. These are preserved, are not counted by
+/// ``DataDestination/count``, and are never overwritten by a patch.
+///
+ public init(_ data: Data = Data()) {
+ self.data = data
+ self.baseOffset = data.count
+ }
+
+/// The number of bytes packed into this destination.
+///
+/// This counts only what was packed, not anything the destination was seeded
+/// with, so it agrees with ``Packer/packedCount``.
+///
+ public var count: Int {
+ data.count - baseOffset
+ }
+
+ public var patchesInPlace: Bool {
+ true
+ }
+
+ public mutating func write(_ bytes: UnsafeRawBufferPointer) throws {
+ guard let baseAddress = bytes.baseAddress, !bytes.isEmpty else {
+ return
+ }
+
+ data.append(baseAddress.assumingMemoryBound(to: UInt8.self), count: bytes.count)
+ }
+
+ public mutating func patch(at offset: Int, with bytes: UnsafeRawBufferPointer) throws {
+ guard offset >= 0, offset + bytes.count <= count else {
+ throw PackError(
+ .destinationFailure,
+ offset: offset,
+ reason: "The range being patched lies outside the bytes written so far"
+ )
+ }
+
+ guard !bytes.isEmpty else {
+ return
+ }
+
+ let start = data.startIndex + baseOffset + offset
+ data.replaceSubrange(start..<(start + bytes.count), with: bytes)
+ }
+
+/// Reserve capacity for the given number of bytes, to avoid reallocating
+/// while packing a body of known size.
+///
+/// - Parameters:
+/// - byteCount: The number of bytes to reserve capacity for.
+///
+ public mutating func reserveCapacity(_ byteCount: Int) {
+ data.reserveCapacity(byteCount)
+ }
+}
diff --git a/Sources/Pack/IO/DataSource.swift b/Sources/Pack/IO/DataSource.swift
new file mode 100644
index 0000000..ce14857
--- /dev/null
+++ b/Sources/Pack/IO/DataSource.swift
@@ -0,0 +1,71 @@
+//
+// DataSource.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+
+/// An ``UnpackSource`` that reads from bytes held in memory.
+///
+public struct DataSource: UnpackSource {
+ private let data: Data
+
+/// The number of bytes consumed so far.
+///
+ public private(set) var consumedCount: Int = 0
+
+/// Initialize a new source.
+///
+/// - Parameters:
+/// - data: The bytes to unpack from.
+///
+ public init(_ data: Data) {
+ self.data = data
+ }
+
+ public var remainingCount: Int? {
+ data.count - consumedCount
+ }
+
+ public mutating func read(into buffer: UnsafeMutableRawBufferPointer) throws {
+ guard !buffer.isEmpty else {
+ return
+ }
+
+ let offset = consumedCount
+ let byteCount = buffer.count
+
+ guard offset + byteCount <= data.count else {
+ throw PackError(
+ .endOfInput,
+ offset: offset,
+ reason: "Requested \(byteCount) bytes, but only \(data.count - offset) remain"
+ )
+ }
+
+ data.withUnsafeBytes { source in
+ buffer.copyMemory(from: UnsafeRawBufferPointer(rebasing: source[offset..<(offset + byteCount)]))
+ }
+
+ consumedCount += byteCount
+ }
+
+ public mutating func skip(_ byteCount: Int) throws {
+ guard byteCount > 0 else {
+ return
+ }
+
+ guard consumedCount + byteCount <= data.count else {
+ throw PackError(
+ .endOfInput,
+ offset: consumedCount,
+ reason: "Cannot skip \(byteCount) bytes, as only \(data.count - consumedCount) remain"
+ )
+ }
+
+ consumedCount += byteCount
+ }
+}
diff --git a/Sources/Pack/IO/InputStreamSource.swift b/Sources/Pack/IO/InputStreamSource.swift
new file mode 100644
index 0000000..dcc6541
--- /dev/null
+++ b/Sources/Pack/IO/InputStreamSource.swift
@@ -0,0 +1,101 @@
+//
+// InputStreamSource.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+
+/// An ``UnpackSource`` that reads from an `InputStream`.
+///
+/// The length of a stream is not knowable in advance, so
+/// ``InputStreamSource/remainingCount`` is always `nil`.
+///
+public struct InputStreamSource: UnpackSource {
+ private let stream: InputStream
+
+/// The number of bytes consumed so far.
+///
+ public private(set) var consumedCount: Int = 0
+
+/// Initialize a new source.
+///
+/// - Parameters:
+/// - stream: The stream to unpack from. The stream must already be open.
+///
+ public init(_ stream: InputStream) {
+ self.stream = stream
+ }
+
+ public var remainingCount: Int? {
+ nil
+ }
+
+ public mutating func read(into buffer: UnsafeMutableRawBufferPointer) throws {
+ guard let baseAddress = buffer.baseAddress, !buffer.isEmpty else {
+ return
+ }
+
+ var pointer = baseAddress.assumingMemoryBound(to: UInt8.self)
+ var remaining = buffer.count
+
+ // A stream is free to return less than was asked for, so reading
+ // continues until the buffer is full.
+ //
+ while remaining > 0 {
+ let read = stream.read(pointer, maxLength: remaining)
+
+ if read > 0 {
+ pointer += read
+ remaining -= read
+ consumedCount += read
+ continue
+ }
+
+ if read == 0 {
+ throw PackError(
+ .endOfInput,
+ offset: consumedCount,
+ reason: "The stream ended with \(remaining) bytes still to read"
+ )
+ }
+
+ switch stream.streamStatus {
+ case .notOpen:
+ throw PackError(.sourceFailure, offset: consumedCount, reason: "The stream is not open")
+ case .closed:
+ throw PackError(.sourceFailure, offset: consumedCount, reason: "The stream has been closed")
+ default:
+ throw PackError(
+ .sourceFailure,
+ offset: consumedCount,
+ reason: "The stream failed to provide the data",
+ underlying: stream.streamError
+ )
+ }
+ }
+ }
+
+ public mutating func skip(_ byteCount: Int) throws {
+ guard byteCount > 0 else {
+ return
+ }
+
+ // A stream cannot seek, so skipped bytes are read and discarded.
+ //
+ var scratch = [UInt8](repeating: .zero, count: Swift.min(byteCount, 4096))
+ var remaining = byteCount
+
+ while remaining > 0 {
+ let chunk = Swift.min(remaining, scratch.count)
+
+ try scratch.withUnsafeMutableBytes { buffer in
+ try read(into: UnsafeMutableRawBufferPointer(rebasing: buffer[0.. 0 {
+ let written = stream.write(pointer, maxLength: remaining)
+
+ if written > 0 {
+ pointer += written
+ remaining -= written
+ count += written
+ continue
+ }
+
+ if written == 0 {
+ throw PackError(
+ .destinationFailure,
+ offset: count,
+ reason: "The stream reached its capacity"
+ )
+ }
+
+ switch stream.streamStatus {
+ case .notOpen:
+ throw PackError(.destinationFailure, offset: count, reason: "The stream is not open")
+ case .closed:
+ throw PackError(.destinationFailure, offset: count, reason: "The stream has been closed")
+ default:
+ throw PackError(
+ .destinationFailure,
+ offset: count,
+ reason: "The stream failed to accept the data",
+ underlying: stream.streamError
+ )
+ }
+ }
+ }
+
+ public mutating func patch(at offset: Int, with bytes: UnsafeRawBufferPointer) throws {
+ throw PackError(
+ .unsupportedOperation,
+ offset: offset,
+ reason: "A stream cannot revise bytes it has already been given"
+ )
+ }
+}
diff --git a/Sources/Pack/IntegerWidth.swift b/Sources/Pack/IntegerWidth.swift
new file mode 100644
index 0000000..29781e6
--- /dev/null
+++ b/Sources/Pack/IntegerWidth.swift
@@ -0,0 +1,66 @@
+//
+// IntegerWidth.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+/// The width of an unsigned integer written into a packed representation.
+///
+/// Pack uses this wherever a size is stored alongside the thing it describes -
+/// a length prefix ahead of a span, a count ahead of a collection, or the
+/// number of bytes ahead of a string. The width is always named by the caller,
+/// because it is part of the layout of the format and cannot be inferred.
+///
+public enum IntegerWidth: Hashable, Sendable, CaseIterable {
+/// An 8-bit unsigned integer, describing up to 255 bytes.
+///
+ case uint8
+
+/// A 16-bit unsigned integer, describing up to 65,535 bytes.
+///
+ case uint16
+
+/// A 32-bit unsigned integer, describing up to 4,294,967,295 bytes.
+///
+ case uint32
+
+/// A 64-bit unsigned integer.
+///
+/// - Note: This is the appropriate width for framing a binary payload, such
+/// as a mesh, an image, or a buffer of samples, any of which can exceed the
+/// 4 GB ceiling imposed by ``uint32``.
+///
+ case uint64
+
+/// The number of bytes occupied by an integer of this width.
+///
+ public var byteCount: Int {
+ switch self {
+ case .uint8:
+ return 1
+ case .uint16:
+ return 2
+ case .uint32:
+ return 4
+ case .uint64:
+ return 8
+ }
+ }
+
+/// The largest value representable at this width.
+///
+ public var maximumValue: UInt64 {
+ switch self {
+ case .uint8:
+ return UInt64(UInt8.max)
+ case .uint16:
+ return UInt64(UInt16.max)
+ case .uint32:
+ return UInt64(UInt32.max)
+ case .uint64:
+ return UInt64.max
+ }
+ }
+}
diff --git a/Sources/Pack/MeasuringPacker.swift b/Sources/Pack/MeasuringPacker.swift
new file mode 100644
index 0000000..549101b
--- /dev/null
+++ b/Sources/Pack/MeasuringPacker.swift
@@ -0,0 +1,120 @@
+//
+// MeasuringPacker.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+
+/// A ``Packer`` that counts the bytes a value would occupy, and produces none.
+///
+/// This is useful for sizing a buffer before packing into it:
+///
+/// ```swift
+/// var measure = MeasuringPacker(configuration: .littleEndian)
+/// try measure.pack(mesh)
+///
+/// var destination = DataDestination()
+/// destination.reserveCapacity(measure.packedCount)
+/// ```
+///
+/// - Note: This is not how a span records its own length. Measuring a body and
+/// then packing it encodes everything twice, which for a large collection is
+/// worse than the buffering it would replace. Use
+/// ``Packer/withLengthPrefix(_:_:)``, which handles both cases without
+/// encoding anything more than once.
+///
+public struct MeasuringPacker: Packer {
+/// The layout rules this packer applies.
+///
+/// Configuration still matters when measuring, as string encoding and
+/// framing both affect how many bytes a value occupies.
+///
+ public let configuration: PackConfiguration
+
+/// The user info for storing local data associated with the current pack.
+///
+ public var userInfo: [PackUserInfoKey: any Sendable] = [:]
+
+/// How many bytes would have been packed.
+///
+ public private(set) var packedCount: Int = 0
+
+ private let identity = PackerIdentity()
+ private var nextToken: UInt64 = 0
+ private var openTokens: Set = []
+
+/// Initialize a new packer.
+///
+/// - Parameters:
+/// - configuration: The layout rules to apply.
+///
+ public init(configuration: PackConfiguration) {
+ self.configuration = configuration
+ }
+
+/// Reserved slots need no special handling when nothing is being written,
+/// so this is always `true`.
+///
+ public var patchesInPlace: Bool {
+ true
+ }
+
+ public var hasOpenPlaceholders: Bool {
+ !openTokens.isEmpty
+ }
+
+ public mutating func write(_ bytes: UnsafeRawBufferPointer) throws {
+ packedCount += bytes.count
+ }
+
+ public mutating func reserve(byteCount: Int) throws -> PackerPlaceholder {
+ guard byteCount > 0 else {
+ throw PackError(
+ .valueOutOfRange,
+ offset: packedCount,
+ reason: "Cannot reserve \(byteCount) bytes"
+ )
+ }
+
+ let token = nextToken
+ nextToken += 1
+ openTokens.insert(token)
+
+ let offset = packedCount
+ packedCount += byteCount
+
+ return PackerPlaceholder(owner: identity, token: token, offset: offset, byteCount: byteCount)
+ }
+
+ public mutating func fill(_ placeholder: PackerPlaceholder, with bytes: UnsafeRawBufferPointer) throws {
+ guard placeholder.isOwned(by: identity) else {
+ throw PackError(
+ .invalidPlaceholder,
+ offset: placeholder.offset,
+ reason: "The placeholder was not created by this packer"
+ )
+ }
+
+ guard bytes.count == placeholder.byteCount else {
+ throw PackError(
+ .valueOutOfRange,
+ offset: placeholder.offset,
+ reason: "\(bytes.count) bytes cannot fill a \(placeholder.byteCount) byte placeholder"
+ )
+ }
+
+ guard openTokens.remove(placeholder.token) != nil else {
+ throw PackError(
+ .placeholderAlreadyFilled,
+ offset: placeholder.offset,
+ reason: "The placeholder has already been filled"
+ )
+ }
+
+ // The bytes themselves are discarded. Their width was already counted
+ // when the slot was reserved.
+ }
+}
diff --git a/Sources/Pack/Pack.docc/BinaryPack+init(byteOrder).md b/Sources/Pack/Pack.docc/BinaryPack+init(byteOrder).md
deleted file mode 100644
index 8192f84..0000000
--- a/Sources/Pack/Pack.docc/BinaryPack+init(byteOrder).md
+++ /dev/null
@@ -1,20 +0,0 @@
-# ``Pack/BinaryPack/init(byteOrder:)``
-
-#### Byte Order
-
-The byte order specified describes the endianness of the bytes being packed.
-
-A big-endian system stores the most significant byte at the smallest memory
-address, and the least significant byte at the largest. A little-endian
-system stores the least-significant byte at the smallest address.
-
-The individual variables are still written out in the order specified, as are
-the bits of each byte, however the byte order within each variable is reversed.
-
-
-
-For more information: [Endianness](https://en.wikipedia.org/wiki/Endianness)
-
-> Note: Little-Endian byte order is most common on modern computing platforms,
-however some file formats still use Big-Endian.
-
diff --git a/Sources/Pack/Pack.docc/BinaryPack+init(from-byteOrder).md b/Sources/Pack/Pack.docc/BinaryPack+init(from-byteOrder).md
deleted file mode 100644
index 21bc074..0000000
--- a/Sources/Pack/Pack.docc/BinaryPack+init(from-byteOrder).md
+++ /dev/null
@@ -1,20 +0,0 @@
-# ``Pack/BinaryPack/init(from:byteOrder:)``
-
-#### Byte Order
-
-The byte order specified describes the endianness of the bytes being unpacked.
-
-A big-endian system stores the most significant byte at the smallest memory
-address, and the least significant byte at the largest. A little-endian
-system stores the least-significant byte at the smallest address.
-
-The individual variables are still read in the order specified, as are the bits\
-of each byte, however the byte order within each variable is reversed.
-
-
-
-For more information: [Endianness](https://en.wikipedia.org/wiki/Endianness)
-
-> Note: Little-Endian byte order is most common on modern computing platforms,
-however some file formats still use Big-Endian.
-
diff --git a/Sources/Pack/Pack.docc/BinaryPack+init(from-options-byteOrder).md b/Sources/Pack/Pack.docc/BinaryPack+init(from-options-byteOrder).md
deleted file mode 100644
index 25998e5..0000000
--- a/Sources/Pack/Pack.docc/BinaryPack+init(from-options-byteOrder).md
+++ /dev/null
@@ -1,20 +0,0 @@
-# ``Pack/BinaryPack/init(from:options:byteOrder:)``
-
-#### Byte Order
-
-The byte order specified describes the endianness of the bytes being unpacked.
-
-A big-endian system stores the most significant byte at the smallest memory
-address, and the least significant byte at the largest. A little-endian
-system stores the least-significant byte at the smallest address.
-
-The individual variables are still read in the order specified, as are the bits\
-of each byte, however the byte order within each variable is reversed.
-
-
-
-For more information: [Endianness](https://en.wikipedia.org/wiki/Endianness)
-
-> Note: Little-Endian byte order is most common on modern computing platforms,
-however some file formats still use Big-Endian.
-
diff --git a/Sources/Pack/Pack.docc/BinaryPack+init(options-byteOrder).md b/Sources/Pack/Pack.docc/BinaryPack+init(options-byteOrder).md
deleted file mode 100644
index d702d6e..0000000
--- a/Sources/Pack/Pack.docc/BinaryPack+init(options-byteOrder).md
+++ /dev/null
@@ -1,20 +0,0 @@
-# ``Pack/BinaryPack/init(options:byteOrder:)``
-
-#### Byte Order
-
-The byte order specified describes the endianness of the bytes being packed.
-
-A big-endian system stores the most significant byte at the smallest memory
-address, and the least significant byte at the largest. A little-endian
-system stores the least-significant byte at the smallest address.
-
-The individual variables are still written out in the order specified, as are
-the bits of each byte, however the byte order within each variable is reversed.
-
-
-
-For more information: [Endianness](https://en.wikipedia.org/wiki/Endianness)
-
-> Note: Little-Endian byte order is most common on modern computing platforms,
-however some file formats still use Big-Endian.
-
diff --git a/Sources/Pack/Pack.docc/BinaryPack+init(readingFrom-byteOrder).md b/Sources/Pack/Pack.docc/BinaryPack+init(readingFrom-byteOrder).md
deleted file mode 100644
index c8983c4..0000000
--- a/Sources/Pack/Pack.docc/BinaryPack+init(readingFrom-byteOrder).md
+++ /dev/null
@@ -1,20 +0,0 @@
-# ``Pack/BinaryPack/init(readingFrom:byteOrder:)``
-
-#### Byte Order
-
-The byte order specified describes the endianness of the bytes being unpacked.
-
-A big-endian system stores the most significant byte at the smallest memory
-address, and the least significant byte at the largest. A little-endian
-system stores the least-significant byte at the smallest address.
-
-The individual variables are still read in the order specified, as are the bits\
-of each byte, however the byte order within each variable is reversed.
-
-
-
-For more information: [Endianness](https://en.wikipedia.org/wiki/Endianness)
-
-> Note: Little-Endian byte order is most common on modern computing platforms,
-however some file formats still use Big-Endian.
-
diff --git a/Sources/Pack/Pack.docc/BinaryPack+init(readingFrom-options-byteOrder).md b/Sources/Pack/Pack.docc/BinaryPack+init(readingFrom-options-byteOrder).md
deleted file mode 100644
index b4b968e..0000000
--- a/Sources/Pack/Pack.docc/BinaryPack+init(readingFrom-options-byteOrder).md
+++ /dev/null
@@ -1,20 +0,0 @@
-# ``Pack/BinaryPack/init(readingFrom:options:byteOrder:)``
-
-#### Byte Order
-
-The byte order specified describes the endianness of the bytes being unpacked.
-
-A big-endian system stores the most significant byte at the smallest memory
-address, and the least significant byte at the largest. A little-endian
-system stores the least-significant byte at the smallest address.
-
-The individual variables are still read in the order specified, as are the bits\
-of each byte, however the byte order within each variable is reversed.
-
-
-
-For more information: [Endianness](https://en.wikipedia.org/wiki/Endianness)
-
-> Note: Little-Endian byte order is most common on modern computing platforms,
-however some file formats still use Big-Endian.
-
diff --git a/Sources/Pack/Pack.docc/BinaryPack+init(writingTo-byteOrder).md b/Sources/Pack/Pack.docc/BinaryPack+init(writingTo-byteOrder).md
deleted file mode 100644
index b4abe9c..0000000
--- a/Sources/Pack/Pack.docc/BinaryPack+init(writingTo-byteOrder).md
+++ /dev/null
@@ -1,20 +0,0 @@
-# ``Pack/BinaryPack/init(writingTo:byteOrder:)``
-
-#### Byte Order
-
-The byte order specified describes the endianness of the bytes being packed.
-
-A big-endian system stores the most significant byte at the smallest memory
-address, and the least significant byte at the largest. A little-endian
-system stores the least-significant byte at the smallest address.
-
-The individual variables are still written out in the order specified, as are
-the bits of each byte, however the byte order within each variable is reversed.
-
-
-
-For more information: [Endianness](https://en.wikipedia.org/wiki/Endianness)
-
-> Note: Little-Endian byte order is most common on modern computing platforms,
-however some file formats still use Big-Endian.
-
diff --git a/Sources/Pack/Pack.docc/BinaryPack+init(writingTo-options-byteOrder).md b/Sources/Pack/Pack.docc/BinaryPack+init(writingTo-options-byteOrder).md
deleted file mode 100644
index 815b891..0000000
--- a/Sources/Pack/Pack.docc/BinaryPack+init(writingTo-options-byteOrder).md
+++ /dev/null
@@ -1,20 +0,0 @@
-# ``Pack/BinaryPack/init(writingTo:options:byteOrder:)``
-
-#### Byte Order
-
-The byte order specified describes the endianness of the bytes being packed.
-
-A big-endian system stores the most significant byte at the smallest memory
-address, and the least significant byte at the largest. A little-endian
-system stores the least-significant byte at the smallest address.
-
-The individual variables are still written out in the order specified, as are
-the bits of each byte, however the byte order within each variable is reversed.
-
-
-
-For more information: [Endianness](https://en.wikipedia.org/wiki/Endianness)
-
-> Note: Little-Endian byte order is most common on modern computing platforms,
-however some file formats still use Big-Endian.
-
diff --git a/Sources/Pack/Pack.docc/BinaryPack.Options+stringsNullTerminated.md b/Sources/Pack/Pack.docc/BinaryPack.Options+stringsNullTerminated.md
deleted file mode 100644
index cd0f13f..0000000
--- a/Sources/Pack/Pack.docc/BinaryPack.Options+stringsNullTerminated.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# ``Pack/BinaryPack/Options-swift.struct/stringsNullTerminated``
-
-Strings being unpacked will stop unpacking when the null terminator is reached.
-
-
-
-Packing and unpacking strings with a null terminator can be slow in some
-instances. For better performance, use ``stringsPrependedWithSize``.
-
-> Important: When null terminating strings, the choices for string encoding
-is limited to `ascii` and `utf8`.
diff --git a/Sources/Pack/Pack.docc/BinaryPack.Options+stringsPrependedWithSize.md b/Sources/Pack/Pack.docc/BinaryPack.Options+stringsPrependedWithSize.md
deleted file mode 100644
index c66cc44..0000000
--- a/Sources/Pack/Pack.docc/BinaryPack.Options+stringsPrependedWithSize.md
+++ /dev/null
@@ -1,12 +0,0 @@
-# ``Pack/BinaryPack/Options-swift.struct/stringsPrependedWithSize``
-
-The size string doesn't describe the number of characters in the string, but
-instead describes the number of bytes in the string. For `ascii` this will
-match the number of characters, but for more complex encodings, this may be
-greater-than or less-than the number of characters.
-
-
-
-> Warning: When writing out strings greater in length than `Int32.max`, a
-different method for marking the length of the string should be used, such as
-``stringsNullTerminated``.
diff --git a/Sources/Pack/Pack.docc/BinaryPack.md b/Sources/Pack/Pack.docc/BinaryPack.md
deleted file mode 100644
index bb7c8cd..0000000
--- a/Sources/Pack/Pack.docc/BinaryPack.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# ``Pack/BinaryPack``
-
-Primitive types packed using the `BinaryPack` object are packed with their
-memory layout matching the runtime memory layout of the specified type,
-wherever possible. For example, an `Int8` type is packed as a single byte
-(8 bits), whereas an `Int16` is packed as 4 bytes (16 bits) of contiguous
-memory.
-
-
-
-## Example
-
-Primitive types can be packed and unpacked directly using a BinaryPack
-object.
-
-```swift
-var packer = BinaryPack()
-try packer.pack(1)
-try packer.pack(2.0)
-try packer.pack("3.0", using: .utf8)
-
-let data = try packer.data
-
-var unpacker = BinaryPack(with: data)
-let int = try packer.unpack(Int.self) // 1
-let double = try packer.unpack(Double.self) // 2.0
-let string = try packer.unpack(String.self, using: .utf8) // "3.0"
-```
-
-> Warning: Modifying the behaviour or memory layout of the BinaryPacker
-after data has been packed will result in loss of data.
diff --git a/Sources/Pack/Pack.docc/Pack.md b/Sources/Pack/Pack.docc/Pack.md
index 487c686..01b4a4d 100644
--- a/Sources/Pack/Pack.docc/Pack.md
+++ b/Sources/Pack/Pack.docc/Pack.md
@@ -1,27 +1,132 @@
# ``Pack``
-Serialize and deserialize various data types into an external binary representation.
+Serialize and deserialize values into an external binary representation.
## Overview
-Pack provides types and protocols for encoding and decoding various data types into external binary representations. This can be useful for storing data efficiently on disk, or for transmission across a network.
+Pack encodes and decodes values as raw binary, for storing data efficiently on
+disk or sending it across a network. It is similar in purpose to the built-in
+`Codable` protocols, but unlike `Codable` it is not key/value based: the layout
+of the bytes is described by the code that writes them, and is the format.
-Different methods for packing and unpacking data can be provided by conforming to the ``Packer`` and ``Unpacker`` protocols, however a built-in ``BinaryPack`` object can pack primitive types such as `Int`, `Float` and `String` into their _in memory_ representation of the data. Additionally, Pack provides the ``Packable`` and ``Unpackable`` protocols, enabling support for encoding and decoding to be added to any Swift type.
-
-Pack is similar in functionality to the built-in `Codable` protocols, however, unlike `Codable`, Pack is not key/value based, and is primarily intend for packing and unpacking binary data for efficient storage and transmission.
+That difference is the point. Every decision that affects the bytes on disk is
+made explicitly at the call site - the byte order, the width of every integer,
+the encoding and framing of every string - and none of it is inherited from the
+machine the code happens to be running on.

+```swift
+var packer = BinaryPacker(configuration: .littleEndian)
+try packer.pack(Int64(12345))
+try packer.pack(6789.0)
+try packer.pack("Hello, world!")
+
+let data = packer.data
+```
+
+Any type can describe how it is packed by conforming to ``Packable`` and
+``Unpackable``:
+
+```swift
+extension Color: Packed {
+ func pack(to packer: inout some Packer) throws {
+ try packer.pack(name, using: .utf16, framing: .lengthPrefixed(.uint32))
+ try packer.pack(red)
+ try packer.pack(green)
+ try packer.pack(blue)
+ try packer.pack(alpha)
+ }
+
+ init(from unpacker: inout some Unpacker) throws {
+ self.name = try unpacker.unpack(String.self, using: .utf16, framing: .lengthPrefixed(.uint32))
+ self.red = try unpacker.unpack(Double.self)
+ self.green = try unpacker.unpack(Double.self)
+ self.blue = try unpacker.unpack(Double.self)
+ self.alpha = try unpacker.unpack(Double.self)
+ }
+}
+```
+
+### Framing
+
+A format usually needs to record how long something is before it has been
+written. ``Packer/withLengthPrefix(_:_:)`` reserves the length, packs the body,
+and fills the length in afterwards:
+
+```swift
+try packer.pack(tag, using: .ascii, framing: .fixed(byteCount: 4))
+try packer.withLengthPrefix(.uint64) { packer in
+ try packer.pack(mesh)
+}
+```
+
+This produces the same bytes whatever the destination. A packer writing to
+memory patches the length in place; a packer writing to a stream, which cannot
+revise what it has already handed over, buffers the body until the length is
+known.
+
+Reading back, ``Unpacker/withLimit(byteCount:_:)`` bounds a span so that a
+length which does not match its contents is reported rather than allowed to run
+on into whatever follows. It also skips anything within the span that was not
+read, which is what lets an older reader accept a span a newer writer has added
+fields to:
+
+```swift
+let length = try unpacker.unpack(UInt64.self)
+try unpacker.withLimit(byteCount: Int(length)) { unpacker in
+ // read the fields this version knows about
+}
+```
+
+### Architecture dependent integers
+
+`Int` and `UInt` change width between platforms, so a file written with them is
+readable only on a machine of matching width - and would not fail on the machine
+it was tested on. Pack's integer APIs are constrained to ``PortableInteger``,
+which `Int` and `UInt` deliberately do not conform to, so using one is a compile
+error rather than a corrupt file discovered later.
+
+```swift
+try packer.pack(Int64(value))
+```
+
## Topics
-### Packing Data
-- ``BinaryPack``
+### Packing and unpacking
+- ``BinaryPacker``
+- ``BinaryUnpacker``
+- ``PackConfiguration``
-### Custom Types
+### Describing a layout
- ``Packed``
- ``Packable``
- ``Unpackable``
+- ``PortableInteger``
+- ``IntegerWidth``
+- ``StringFraming``
+- ``ByteOrder``
-### Packer and Unpacker
+### Framing
+- ``PackerPlaceholder``
+
+### Protocols
- ``Packer``
- ``Unpacker``
+- ``PackDestination``
+- ``UnpackSource``
+
+### Destinations and sources
+- ``DataDestination``
+- ``OutputStreamDestination``
+- ``DataSource``
+- ``InputStreamSource``
+
+### Other packers
+- ``MeasuringPacker``
+- ``AnyPacker``
+- ``AnyUnpacker``
+
+### Supporting types
+- ``PackError``
+- ``PackUserInfoKey``
diff --git a/Sources/Pack/PackConfiguration.swift b/Sources/Pack/PackConfiguration.swift
new file mode 100644
index 0000000..89c7563
--- /dev/null
+++ b/Sources/Pack/PackConfiguration.swift
@@ -0,0 +1,65 @@
+//
+// PackConfiguration.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+
+/// The layout rules a packer or unpacker applies.
+///
+/// Everything that affects the bytes on disk is gathered here and pinned at
+/// construction. There is no process-wide default and no host-dependent
+/// fallback, because a format cannot be built on either - the same source must
+/// produce the same bytes on every machine that runs it.
+///
+/// Byte order has no default value. You name an endianness, or you do not get
+/// a packer.
+///
+public struct PackConfiguration: Hashable, Sendable {
+/// The byte order used for multi-byte scalar values.
+///
+/// String data is never reordered. Where a specific byte order is required
+/// for text, use the corresponding encoding, such as `utf16BigEndian`.
+///
+ public var byteOrder: ByteOrder
+
+/// The string encoding used when a call does not name one.
+///
+ public var defaultStringEncoding: String.Encoding
+
+/// The string framing used when a call does not name one.
+///
+ public var defaultStringFraming: StringFraming
+
+/// Initialize a new configuration.
+///
+/// - Parameters:
+/// - byteOrder: The byte order used for multi-byte scalar values.
+/// - defaultStringEncoding: The string encoding used when a call does not
+/// name one.
+/// - defaultStringFraming: The string framing used when a call does not
+/// name one.
+///
+ public init(
+ byteOrder: ByteOrder,
+ defaultStringEncoding: String.Encoding = .utf8,
+ defaultStringFraming: StringFraming = .lengthPrefixed(.uint32)
+ ) {
+ self.byteOrder = byteOrder
+ self.defaultStringEncoding = defaultStringEncoding
+ self.defaultStringFraming = defaultStringFraming
+ }
+
+/// A little-endian configuration, packing strings as utf8 prefixed with a
+/// 32-bit byte count.
+///
+ public static let littleEndian = PackConfiguration(byteOrder: .littleEndian)
+
+/// A big-endian configuration, packing strings as utf8 prefixed with a
+/// 32-bit byte count.
+///
+ public static let bigEndian = PackConfiguration(byteOrder: .bigEndian)
+}
diff --git a/Sources/Pack/PackError.swift b/Sources/Pack/PackError.swift
index 6752f2c..d6a12bf 100644
--- a/Sources/Pack/PackError.swift
+++ b/Sources/Pack/PackError.swift
@@ -6,90 +6,165 @@
// Copyright © 2024 Matt Cox. All rights reserved.
//
-/// Errors used by Pack.
+import Foundation
+
+/// An error raised while packing or unpacking.
///
-public enum PackError: Swift.Error {
-/// An Error indicating a general failure.
+/// The failure kind is carried by ``PackError/Code-swift.struct`` rather than
+/// by the case of an enumeration, so that new conditions can be introduced
+/// without breaking clients that switch over the existing ones.
///
-/// - Parameters:
-/// - reason: An optional string describing the reason for the failure.
+/// Where the position of the failure is known it is reported in
+/// ``PackError/offset``, because locating a fault in a binary file without one
+/// is largely guesswork.
///
- case failure(reason: String? = nil)
-
-/// Indicates that a packing operation of some form has been attempted, but
-/// data is currently not being packed.
+public struct PackError: Error {
+/// Identifies the kind of failure that occurred.
+///
+ public struct Code: Hashable, Sendable, CustomStringConvertible {
+ private let name: String
+
+ private init(_ name: String) {
+ self.name = name
+ }
+
+ public var description: String {
+ name
+ }
+
+/// The source ended before the requested value could be read.
///
- case notPacking
+ public static let endOfInput = Code("endOfInput")
-/// Indicates that a unpacking operation of some form has been attempted,
-/// but data is currently not being unpacked.
+/// A value extended beyond the limit declared for the span containing it.
///
- case notUnpacking
-
-/// Indicates that the destination for packing data is invalid.
+/// This indicates a corrupt or mismatched length, and is reported rather
+/// than allowing the read to continue into whatever follows.
///
-/// - Parameters:
-/// - reason: An optional string describing the reason for the failure.
+ public static let overrun = Code("overrun")
+
+/// A value could not be represented in the width chosen for it.
///
- case invalidPackingDestination
-
-/// Indicates that the data source for unpacking is invalid.
+/// This covers a length prefix too narrow for the span it describes, a
+/// count too large for its prefix width, and string data longer than a
+/// fixed-size field.
///
-/// - Parameters:
-/// - reason: An optional string describing the reason for the failure.
+ public static let valueOutOfRange = Code("valueOutOfRange")
+
+/// String data could not be encoded or decoded as requested.
+///
+ public static let invalidString = Code("invalidString")
+
+/// The requested string encoding cannot be used with the requested
+/// framing.
+///
+ public static let unsupportedEncoding = Code("unsupportedEncoding")
+
+/// The packer or unpacker does not support the requested operation.
+///
+ public static let unsupportedOperation = Code("unsupportedOperation")
+
+/// A placeholder was used with a packer that did not create it.
+///
+ public static let invalidPlaceholder = Code("invalidPlaceholder")
+
+/// A placeholder was filled more than once.
+///
+ public static let placeholderAlreadyFilled = Code("placeholderAlreadyFilled")
+
+/// Packing ended while a reserved slot was still unfilled, leaving the
+/// output structurally invalid.
+///
+ public static let incompleteOutput = Code("incompleteOutput")
+
+/// The destination being packed into failed to accept the data.
+///
+ public static let destinationFailure = Code("destinationFailure")
+
+/// The source being unpacked from failed to provide the data.
+///
+ public static let sourceFailure = Code("sourceFailure")
+ }
+
+/// The kind of failure that occurred.
+///
+ public let code: Code
+
+/// The byte offset at which the failure occurred, where known.
///
- case invalidUnpackingSource
-
-/// Indicates that either there is not enough room in the destination object
-/// to pack the provided data, or there is not enough data in the source
-/// object to read.
+/// For packing this is the number of bytes written so far; for unpacking,
+/// the number consumed.
///
- case shortBuffer
-
-/// Indicates that the specified string encoding is unsupported.
+ public let offset: Int?
+
+/// A human readable description of what went wrong, where the code alone is
+/// not specific enough.
+///
+ public let reason: String?
+
+/// The underlying error reported by a destination or source, where there was
+/// one.
///
- case unsupportedEncoding
-
-/// Indicates a general "unknown" error.
+ public let underlying: (any Error)?
+
+/// Initialize a new error.
///
-/// This error is mostly a catch-all and should be avoided where possible.
+/// - Parameters:
+/// - code: The kind of failure that occurred.
+/// - offset: The byte offset at which the failure occurred.
+/// - reason: A human readable description of what went wrong.
+/// - underlying: The underlying error reported by a destination or source.
///
- case unknown
+ public init(
+ _ code: Code,
+ offset: Int? = nil,
+ reason: String? = nil,
+ underlying: (any Error)? = nil
+ ) {
+ self.code = code
+ self.offset = offset
+ self.reason = reason
+ self.underlying = underlying
+ }
}
-// Conform Error to CustomDebugStringConvertible.
+// `underlying` holds an arbitrary error reported by a destination or source,
+// which cannot be statically known to be Sendable. PackError itself adds no
+// mutable state, so it is safe to send provided the underlying error is - which
+// is the case for the stream errors Pack itself produces.
//
+extension PackError: @unchecked Sendable {
+
+}
+
+extension PackError: CustomStringConvertible {
+ public var description: String {
+ var description = "\(code)"
+
+ if let reason {
+ description += ": \(reason)"
+ }
+
+ if let offset {
+ description += " (at byte \(offset))"
+ }
+
+ if let underlying {
+ description += " [\(underlying)]"
+ }
+
+ return description
+ }
+}
+
extension PackError: CustomDebugStringConvertible {
public var debugDescription: String {
- switch self {
- case .failure(let reason):
- if let reason = reason {
- return "Failed: \(reason)"
- }
- else {
- return "Failed"
- }
-
- case .notPacking:
- return "Not Packing"
-
- case .notUnpacking:
- return "Not Unpacking"
-
- case .invalidPackingDestination:
- return "Invalid Packing Destination"
-
- case .invalidUnpackingSource:
- return "Invalid Unpacking Source"
-
- case .shortBuffer:
- return "Short Buffer"
-
- case .unsupportedEncoding:
- return "Unsupported Encoding"
-
- case .unknown:
- return "Unknown"
- }
+ description
+ }
+}
+
+extension PackError: LocalizedError {
+ public var errorDescription: String? {
+ description
}
}
diff --git a/Sources/Pack/Packer+Packing.swift b/Sources/Pack/Packer+Packing.swift
new file mode 100644
index 0000000..85fced6
--- /dev/null
+++ b/Sources/Pack/Packer+Packing.swift
@@ -0,0 +1,559 @@
+//
+// Packer+Packing.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+
+// The packing API. None of this is a protocol requirement - it is defined once
+// here and shared by every packer, so that the encoding rules exist in exactly
+// one place and the required surface stays small and non-generic.
+//
+extension Packer {
+/// Pack a fixed width integer into the packer.
+///
+/// The value is written in the byte order named by the packer's
+/// ``PackConfiguration``.
+///
+/// - Parameters:
+/// - value: The integer to pack.
+///
+/// - Throws: ``PackError`` if the destination could not accept the bytes.
+///
+ public mutating func pack(_ value: T) throws {
+ try packInteger(value)
+ }
+
+// The implementation behind every integer entry point. The public overloads
+// forward here rather than to each other, so that the concrete overloads below
+// cannot recurse into themselves.
+//
+ private mutating func packInteger(_ value: T) throws {
+ let ordered = configuration.byteOrder == .littleEndian ? value.littleEndian : value.bigEndian
+ try withUnsafeBytes(of: ordered) {
+ try write($0)
+ }
+ }
+
+// The sized integer types conform to Packable as well as FixedWidthInteger, so
+// a call such as `pack(someInt32)` matches two generic entry points, and
+// neither is more specialised than the other. These concrete overloads settle
+// it: being concrete, they are more specific than either.
+//
+// They are conveniences rather than protocol requirements, so the required
+// surface stays non-generic and small - see ``Packer``.
+//
+ public mutating func pack(_ value: Int8) throws {
+ try packInteger(value)
+ }
+
+ public mutating func pack(_ value: Int16) throws {
+ try packInteger(value)
+ }
+
+ public mutating func pack(_ value: Int32) throws {
+ try packInteger(value)
+ }
+
+ public mutating func pack(_ value: Int64) throws {
+ try packInteger(value)
+ }
+
+ public mutating func pack(_ value: UInt8) throws {
+ try packInteger(value)
+ }
+
+ public mutating func pack(_ value: UInt16) throws {
+ try packInteger(value)
+ }
+
+ public mutating func pack(_ value: UInt32) throws {
+ try packInteger(value)
+ }
+
+ public mutating func pack(_ value: UInt64) throws {
+ try packInteger(value)
+ }
+
+/// Pack a `Bool` into the packer, as a single byte.
+///
+/// - Parameters:
+/// - value: The `Bool` to pack.
+///
+/// - Throws: ``PackError`` if the destination could not accept the bytes.
+///
+ public mutating func pack(_ value: Bool) throws {
+ try pack(value ? UInt8(1) : UInt8(0))
+ }
+
+/// Pack a `Float` into the packer, as its 32-bit bit pattern.
+///
+/// - Parameters:
+/// - value: The `Float` to pack.
+///
+/// - Throws: ``PackError`` if the destination could not accept the bytes.
+///
+ public mutating func pack(_ value: Float) throws {
+ try pack(value.bitPattern)
+ }
+
+/// Pack a `Double` into the packer, as its 64-bit bit pattern.
+///
+/// - Parameters:
+/// - value: The `Double` to pack.
+///
+/// - Throws: ``PackError`` if the destination could not accept the bytes.
+///
+ public mutating func pack(_ value: Double) throws {
+ try pack(value.bitPattern)
+ }
+
+/// Pack bytes stored in a `Data` object into the packer.
+///
+/// The bytes are written exactly as given, and are not reordered.
+///
+/// - Parameters:
+/// - data: The `Data` containing the bytes to pack.
+///
+/// - Throws: ``PackError`` if the destination could not accept the bytes.
+///
+ public mutating func pack(_ data: Data) throws {
+ guard !data.isEmpty else {
+ return
+ }
+
+ try data.withUnsafeBytes {
+ try write($0)
+ }
+ }
+
+/// Pack a type conforming to ``Packable`` into the packer.
+///
+/// - Parameters:
+/// - value: The value to pack.
+///
+/// - Throws: ``PackError`` if the value could not be packed.
+///
+ public mutating func pack(_ value: T) throws {
+ try value.pack(to: &self)
+ }
+
+/// Pack a `String` into the packer.
+///
+/// The byte order named by the packer's ``PackConfiguration`` is not applied
+/// to string data. Where a specific byte order is required, use the
+/// corresponding encoding, such as `utf16BigEndian`.
+///
+/// - Parameters:
+/// - value: The `String` to pack.
+/// - encoding: The encoding used to convert the string to bytes. Defaults
+/// to the encoding named by the packer's configuration.
+/// - framing: How the extent of the string is recorded. Defaults to the
+/// framing named by the packer's configuration.
+///
+/// - Throws: ``PackError`` if the string cannot be represented in the given
+/// encoding, if the encoding and framing are incompatible, or if the string
+/// does not fit a fixed size field.
+///
+ public mutating func pack(
+ _ value: String,
+ using encoding: String.Encoding? = nil,
+ framing: StringFraming? = nil
+ ) throws {
+ let encoding = encoding ?? configuration.defaultStringEncoding
+ let framing = framing ?? configuration.defaultStringFraming
+
+ guard let data = value.data(using: encoding) else {
+ throw PackError(
+ .invalidString,
+ offset: packedCount,
+ reason: "The string could not be represented in the requested encoding"
+ )
+ }
+
+ switch framing {
+ case .lengthPrefixed(let width):
+ try pack(count: data.count, width: width, describing: "string")
+ try pack(data)
+
+ case .nullTerminated:
+ guard encoding == .ascii || encoding == .utf8 else {
+ throw PackError(
+ .unsupportedEncoding,
+ offset: packedCount,
+ reason: "Only ascii and utf8 strings may be null terminated"
+ )
+ }
+
+ guard !data.contains(.zero) else {
+ throw PackError(
+ .invalidString,
+ offset: packedCount,
+ reason: "A null terminated string may not contain a null character"
+ )
+ }
+
+ try pack(data)
+ try pack(UInt8.zero)
+
+ case .fixed(let byteCount, let padding):
+ guard data.count <= byteCount else {
+ throw PackError(
+ .valueOutOfRange,
+ offset: packedCount,
+ reason: "The string occupies \(data.count) bytes, which exceeds the \(byteCount) byte field"
+ )
+ }
+
+ try pack(data)
+
+ if data.count < byteCount {
+ let bytes = [UInt8](repeating: padding, count: byteCount - data.count)
+ try bytes.withUnsafeBytes {
+ try write($0)
+ }
+ }
+
+ case .untilEnd:
+ try pack(data)
+ }
+ }
+
+/// Pack the elements of a collection of fixed width integers.
+///
+/// Nothing is written to describe how many elements there are. Use
+/// `pack(contentsOf:countPrefixedBy:)` where the count must be recoverable.
+///
+/// - Parameters:
+/// - values: The values to pack, in order.
+///
+/// - Throws: ``PackError`` if the destination could not accept the bytes.
+///
+ public mutating func pack(contentsOf values: C) throws where C.Element: PortableInteger {
+ // Where no reordering is required the elements are already laid out
+ // correctly in memory, so contiguous storage can be handed over whole.
+ //
+ if MemoryLayout.size == 1 || configuration.byteOrder == ByteOrder.system {
+ let packed = try values.withContiguousStorageIfAvailable { buffer in
+ try write(UnsafeRawBufferPointer(buffer))
+ return true
+ }
+
+ if packed == true {
+ return
+ }
+ }
+
+ for value in values {
+ try pack(value)
+ }
+ }
+
+/// Pack the elements of a collection of `Float` values.
+///
+/// - Parameters:
+/// - values: The values to pack, in order.
+///
+/// - Throws: ``PackError`` if the destination could not accept the bytes.
+///
+ public mutating func pack(contentsOf values: C) throws where C.Element == Float {
+ if configuration.byteOrder == ByteOrder.system {
+ let packed = try values.withContiguousStorageIfAvailable { buffer in
+ try write(UnsafeRawBufferPointer(buffer))
+ return true
+ }
+
+ if packed == true {
+ return
+ }
+ }
+
+ for value in values {
+ try pack(value)
+ }
+ }
+
+/// Pack the elements of a collection of `Double` values.
+///
+/// - Parameters:
+/// - values: The values to pack, in order.
+///
+/// - Throws: ``PackError`` if the destination could not accept the bytes.
+///
+ public mutating func pack(contentsOf values: C) throws where C.Element == Double {
+ if configuration.byteOrder == ByteOrder.system {
+ let packed = try values.withContiguousStorageIfAvailable { buffer in
+ try write(UnsafeRawBufferPointer(buffer))
+ return true
+ }
+
+ if packed == true {
+ return
+ }
+ }
+
+ for value in values {
+ try pack(value)
+ }
+ }
+
+/// Pack the elements of a collection of fixed width integers, preceded by
+/// the number of elements.
+///
+/// - Parameters:
+/// - values: The values to pack, in order.
+/// - width: The width of the unsigned integer recording the element count.
+///
+/// - Throws: ``PackError`` if the count does not fit the given width, or if
+/// the destination could not accept the bytes.
+///
+ public mutating func pack(
+ contentsOf values: C,
+ countPrefixedBy width: IntegerWidth
+ ) throws where C.Element: PortableInteger {
+ try pack(count: values.count, width: width, describing: "collection")
+ try pack(contentsOf: values)
+ }
+
+/// Pack the elements of a collection of `Float` values, preceded by the
+/// number of elements.
+///
+/// - Parameters:
+/// - values: The values to pack, in order.
+/// - width: The width of the unsigned integer recording the element count.
+///
+/// - Throws: ``PackError`` if the count does not fit the given width, or if
+/// the destination could not accept the bytes.
+///
+ public mutating func pack(
+ contentsOf values: C,
+ countPrefixedBy width: IntegerWidth
+ ) throws where C.Element == Float {
+ try pack(count: values.count, width: width, describing: "collection")
+ try pack(contentsOf: values)
+ }
+
+/// Pack the elements of a collection of `Double` values, preceded by the
+/// number of elements.
+///
+/// - Parameters:
+/// - values: The values to pack, in order.
+/// - width: The width of the unsigned integer recording the element count.
+///
+/// - Throws: ``PackError`` if the count does not fit the given width, or if
+/// the destination could not accept the bytes.
+///
+ public mutating func pack(
+ contentsOf values: C,
+ countPrefixedBy width: IntegerWidth
+ ) throws where C.Element == Double {
+ try pack(count: values.count, width: width, describing: "collection")
+ try pack(contentsOf: values)
+ }
+
+/// Assert that packing is complete.
+///
+/// A reserved slot that is never filled leaves the output structurally
+/// invalid, and neither destination can report that on its own: a patchable
+/// destination leaves the slot as zeroes, and a buffering destination never
+/// writes the span at all. Call this once packing is done, and treat the
+/// output as unusable if it throws.
+///
+/// ```swift
+/// try packer.pack(document)
+/// try packer.finish()
+///
+/// let data = packer.data
+/// ```
+///
+/// - Throws: ``PackError`` with code `incompleteOutput` if any reserved slot
+/// has not been filled.
+///
+ public func finish() throws {
+ guard !hasOpenPlaceholders else {
+ throw PackError(
+ .incompleteOutput,
+ offset: packedCount,
+ reason: "Packing ended with a reserved slot still unfilled, so the output is incomplete"
+ )
+ }
+ }
+
+/// Reserve a fixed width integer to be filled in later.
+///
+/// - Parameters:
+/// - type: The type of integer to reserve space for.
+///
+/// - Returns: A placeholder identifying the reserved bytes.
+///
+/// - Throws: ``PackError`` if the bytes could not be reserved.
+///
+ public mutating func reserve(_ type: T.Type) throws -> PackerPlaceholder {
+ try reserve(byteCount: MemoryLayout.size)
+ }
+
+/// Fill a previously reserved fixed width integer.
+///
+/// - Parameters:
+/// - placeholder: The placeholder identifying the reserved bytes.
+/// - value: The value to write into the reserved slot.
+///
+/// - Throws: ``PackError`` if the placeholder does not belong to this
+/// packer, was already filled, or if the value does not match the reserved
+/// width.
+///
+ public mutating func fill(_ placeholder: PackerPlaceholder, with value: T) throws {
+ guard MemoryLayout.size == placeholder.byteCount else {
+ throw PackError(
+ .valueOutOfRange,
+ offset: placeholder.offset,
+ reason: "A \(MemoryLayout.size) byte value cannot fill a \(placeholder.byteCount) byte placeholder"
+ )
+ }
+
+ let ordered = configuration.byteOrder == .littleEndian ? value.littleEndian : value.bigEndian
+ try withUnsafeBytes(of: ordered) {
+ try fill(placeholder, with: $0)
+ }
+ }
+
+/// Pack a span, preceded by its length in bytes.
+///
+/// This is the intended way to frame a body whose size is not known until it
+/// has been packed. The length is reserved, the body is packed, and the
+/// length is filled in afterwards.
+///
+/// ```swift
+/// try packer.pack(tag, using: .ascii, framing: .fixed(byteCount: 4))
+/// try packer.withLengthPrefix(.uint64) { packer in
+/// try packer.pack(mesh)
+/// }
+/// ```
+///
+/// The bytes produced do not depend on whether the packer can patch its
+/// output in place. A packer writing to a stream buffers the body until the
+/// length is known; a packer writing to memory patches the length directly.
+/// Both produce the same file.
+///
+/// - Warning: If `body` throws, the span is left open and its length is
+/// never written. The output is incomplete from that point on - a buffering
+/// destination will not have received the span at all, and will silently
+/// swallow anything packed after it. Call ``finish()`` before using the
+/// output, which reports that state as an error.
+///
+/// - Parameters:
+/// - width: The width of the unsigned integer recording the byte count.
+/// Prefer `.uint64` for a binary payload, which can exceed the 4 GB
+/// ceiling imposed by `.uint32`.
+/// - body: A closure packing the contents of the span.
+///
+/// - Returns: The value returned by `body`.
+///
+/// - Throws: ``PackError`` if the span's length does not fit the given
+/// width, or if packing fails.
+///
+ @discardableResult
+ public mutating func withLengthPrefix(
+ _ width: IntegerWidth,
+ _ body: (inout Self) throws -> R
+ ) throws -> R {
+ let placeholder = try reserve(byteCount: width.byteCount)
+ let start = packedCount
+
+ let result = try body(&self)
+
+ let length = packedCount - start
+ guard UInt64(length) <= width.maximumValue else {
+ throw PackError(
+ .valueOutOfRange,
+ offset: placeholder.offset,
+ reason: "A span of \(length) bytes cannot be described by a \(width.byteCount) byte length"
+ )
+ }
+
+ switch width {
+ case .uint8:
+ try fill(placeholder, with: UInt8(length))
+ case .uint16:
+ try fill(placeholder, with: UInt16(length))
+ case .uint32:
+ try fill(placeholder, with: UInt32(length))
+ case .uint64:
+ try fill(placeholder, with: UInt64(length))
+ }
+
+ return result
+ }
+
+// Packs a count at the given width, reporting a value that does not fit in
+// terms of what was being counted.
+//
+ private mutating func pack(count: Int, width: IntegerWidth, describing subject: String) throws {
+ guard count >= 0, UInt64(count) <= width.maximumValue else {
+ throw PackError(
+ .valueOutOfRange,
+ offset: packedCount,
+ reason: "A \(subject) of \(count) cannot be described by a \(width.byteCount) byte count"
+ )
+ }
+
+ switch width {
+ case .uint8:
+ try pack(UInt8(count))
+ case .uint16:
+ try pack(UInt16(count))
+ case .uint32:
+ try pack(UInt32(count))
+ case .uint64:
+ try pack(UInt64(count))
+ }
+ }
+}
+
+// Architecture dependent integers are blocked at the call site rather than
+// warned about in documentation.
+//
+// The block itself comes from ``PortableInteger``, which Int and UInt do not
+// conform to. These unavailable overloads exist to turn the conformance failure
+// into a message that says what to do instead.
+//
+extension Packer {
+ @available(*, unavailable, message: "Int is architecture-dependent and would produce a file readable only on the machine that wrote it. Widen explicitly: pack(Int64(value))")
+ public mutating func pack(_ value: Int) throws {
+ fatalError("unavailable")
+ }
+
+ @available(*, unavailable, message: "UInt is architecture-dependent and would produce a file readable only on the machine that wrote it. Widen explicitly: pack(UInt64(value))")
+ public mutating func pack(_ value: UInt) throws {
+ fatalError("unavailable")
+ }
+
+ @available(*, unavailable, message: "Int is architecture-dependent and would produce a file readable only on the machine that wrote it. Map to a sized type first: pack(contentsOf: values.map(Int64.init))")
+ public mutating func pack(contentsOf values: C) throws where C.Element == Int {
+ fatalError("unavailable")
+ }
+
+ @available(*, unavailable, message: "UInt is architecture-dependent and would produce a file readable only on the machine that wrote it. Map to a sized type first: pack(contentsOf: values.map(UInt64.init))")
+ public mutating func pack(contentsOf values: C) throws where C.Element == UInt {
+ fatalError("unavailable")
+ }
+
+ @available(*, unavailable, message: "Int is architecture-dependent and would produce a file readable only on the machine that wrote it. Map to a sized type first: pack(contentsOf: values.map(Int64.init), countPrefixedBy: width)")
+ public mutating func pack(
+ contentsOf values: C,
+ countPrefixedBy width: IntegerWidth
+ ) throws where C.Element == Int {
+ fatalError("unavailable")
+ }
+
+ @available(*, unavailable, message: "UInt is architecture-dependent and would produce a file readable only on the machine that wrote it. Map to a sized type first: pack(contentsOf: values.map(UInt64.init), countPrefixedBy: width)")
+ public mutating func pack(
+ contentsOf values: C,
+ countPrefixedBy width: IntegerWidth
+ ) throws where C.Element == UInt {
+ fatalError("unavailable")
+ }
+}
diff --git a/Sources/Pack/PackerPlaceholder.swift b/Sources/Pack/PackerPlaceholder.swift
new file mode 100644
index 0000000..2a8fed5
--- /dev/null
+++ b/Sources/Pack/PackerPlaceholder.swift
@@ -0,0 +1,54 @@
+//
+// PackerPlaceholder.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+/// Establishes the identity of a packer, so that a ``PackerPlaceholder`` can
+/// be traced back to the packer that vended it.
+///
+/// This exists so that value type packers have a stable identity too, which
+/// `ObjectIdentifier` alone could not provide.
+///
+public final class PackerIdentity: Sendable {
+ public init() {
+
+ }
+}
+
+/// A position in the packed output whose value is not yet known.
+///
+/// A placeholder is obtained from ``Packer/reserve(byteCount:)`` and consumed
+/// by ``Packer/fill(_:with:)``. It carries the identity of the packer that
+/// created it, so passing one to a different packer is detected rather than
+/// silently corrupting the output.
+///
+/// Most code should not handle placeholders directly.
+/// ``Packer/withLengthPrefix(_:_:)`` reserves and fills one correctly, and is
+/// the intended way to frame a span whose length is not known in advance.
+///
+public struct PackerPlaceholder: Hashable, Sendable {
+ internal let owner: ObjectIdentifier
+ internal let token: UInt64
+
+/// The offset at which the reserved bytes begin.
+///
+ public let offset: Int
+
+/// The number of bytes reserved.
+///
+ public let byteCount: Int
+
+ internal init(owner: PackerIdentity, token: UInt64, offset: Int, byteCount: Int) {
+ self.owner = ObjectIdentifier(owner)
+ self.token = token
+ self.offset = offset
+ self.byteCount = byteCount
+ }
+
+ internal func isOwned(by identity: PackerIdentity) -> Bool {
+ owner == ObjectIdentifier(identity)
+ }
+}
diff --git a/Sources/Pack/PortableInteger.swift b/Sources/Pack/PortableInteger.swift
new file mode 100644
index 0000000..3b29246
--- /dev/null
+++ b/Sources/Pack/PortableInteger.swift
@@ -0,0 +1,63 @@
+//
+// PortableInteger.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+/// An integer whose width does not depend on the architecture it is compiled
+/// for, and which is therefore safe to write into a file.
+///
+/// Pack's integer APIs are constrained to this rather than to
+/// `FixedWidthInteger`, so that `Int` and `UInt` - which are 32 bits on some
+/// platforms and 64 on others - cannot be packed by accident. A file written
+/// with `Int` is readable only on a machine of matching width, and would not
+/// fail on the machine it was tested on.
+///
+/// Widen explicitly at the call site instead:
+///
+/// ```swift
+/// try packer.pack(Int64(someInt))
+/// ```
+///
+/// A custom integer type of fixed width may conform, and can then be packed
+/// like any other.
+///
+/// - Note: `Int` and `UInt` deliberately do not conform, and never should.
+///
+public protocol PortableInteger: FixedWidthInteger {
+
+}
+
+extension Int8: PortableInteger {
+
+}
+
+extension Int16: PortableInteger {
+
+}
+
+extension Int32: PortableInteger {
+
+}
+
+extension Int64: PortableInteger {
+
+}
+
+extension UInt8: PortableInteger {
+
+}
+
+extension UInt16: PortableInteger {
+
+}
+
+extension UInt32: PortableInteger {
+
+}
+
+extension UInt64: PortableInteger {
+
+}
diff --git a/Sources/Pack/Protocols/PackDestination.swift b/Sources/Pack/Protocols/PackDestination.swift
new file mode 100644
index 0000000..2e4a030
--- /dev/null
+++ b/Sources/Pack/Protocols/PackDestination.swift
@@ -0,0 +1,49 @@
+//
+// PackDestination.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+/// A sink that packed bytes are written into.
+///
+/// Separating the sink from the codec is what allows a single ``BinaryPacker``
+/// implementation to serve memory, streams, and anything a client cares to add
+/// - a memory mapped file, a socket, a compressing sink - without any of them
+/// reimplementing how a value becomes bytes.
+///
+public protocol PackDestination {
+/// The number of bytes written to this destination.
+///
+ var count: Int { get }
+
+/// Whether bytes already written can be revised in place.
+///
+/// A destination that has handed its output onwards, such as one wrapping a
+/// stream, cannot. ``BinaryPacker`` compensates by buffering, so this
+/// affects peak memory rather than the bytes produced.
+///
+ var patchesInPlace: Bool { get }
+
+/// Append bytes to the destination.
+///
+/// - Parameters:
+/// - bytes: The bytes to append.
+///
+/// - Throws: ``PackError`` if the bytes could not be written.
+///
+ mutating func write(_ bytes: UnsafeRawBufferPointer) throws
+
+/// Revise bytes already written.
+///
+/// - Parameters:
+/// - offset: The offset at which to write, which is always within the
+/// bytes already written.
+/// - bytes: The bytes to write.
+///
+/// - Throws: ``PackError`` if the destination cannot patch, or if the range
+/// lies outside the bytes written so far.
+///
+ mutating func patch(at offset: Int, with bytes: UnsafeRawBufferPointer) throws
+}
diff --git a/Sources/Pack/Protocols/Packable.swift b/Sources/Pack/Protocols/Packable.swift
index 92dc07d..80d756e 100644
--- a/Sources/Pack/Protocols/Packable.swift
+++ b/Sources/Pack/Protocols/Packable.swift
@@ -8,6 +8,29 @@
/// A type that can pack itself into an external representation.
///
+/// The packer is a generic parameter rather than an existential, so that
+/// packing a value specialises to direct calls on the concrete packer instead
+/// of dispatching dynamically for every field.
+///
+/// Conformances read the same as they would with an existential, using `some`:
+///
+/// ```swift
+/// extension Color: Packable {
+/// func pack(to packer: inout some Packer) throws {
+/// try packer.pack(name, framing: .lengthPrefixed(.uint32))
+/// try packer.pack(red)
+/// try packer.pack(green)
+/// try packer.pack(blue)
+/// try packer.pack(alpha)
+/// }
+/// }
+/// ```
+///
+/// Where the packer's concrete type genuinely cannot be known - dispatching
+/// through a class-bound existential, or invoking a closure captured before any
+/// packer existed - wrap it in ``AnyPacker``, which is a concrete type and so
+/// satisfies the generic parameter.
+///
public protocol Packable {
/// Packs this value using the provided packer.
///
@@ -17,5 +40,5 @@ public protocol Packable {
/// - Throws: ``PackError`` if any values are invalid for the given packer
/// format.
///
- func pack(to packer: inout Packer) throws
+ func pack(to packer: inout P) throws
}
diff --git a/Sources/Pack/Protocols/Packer.swift b/Sources/Pack/Protocols/Packer.swift
index e715dfc..2d4b6e0 100644
--- a/Sources/Pack/Protocols/Packer.swift
+++ b/Sources/Pack/Protocols/Packer.swift
@@ -8,204 +8,104 @@
import Foundation
-/// A type that can pack various types into an external representation.
+/// A type that can pack values into an external representation.
///
-public protocol Packer {
-/// Get a `Data` object containing the packed data.
-///
-/// - Throws: ``PackError`` if the packer is writing to an `OutputStream`.
-///
- var data: Data { get throws }
-
-/// Indicates if the packer is currently packing.
-///
-/// This is useful in cases where an object acts as both a Packer and an
-/// ``Unpacker``, and can only perform either a pack or an unpack at once.
-///
- var isPacking: Bool { get }
-
-/// The user info for storing local data associated with the current pack.
-///
- var userInfo: [PackUserInfoKey: Any] { get set }
-
-/// Initialize a new Packer, writing to a `Data` object owned by the packer.
-///
- init()
-
-/// Initialize a new Packer, writing to the provided `OutputStream`.
-///
-/// - Parameters:
-/// - stream: The stream to pack the data into.
-///
- init(writingTo stream: OutputStream)
-
-/// Pack a `Bool` type value into the packer.
-///
-/// - Parameters:
-/// - value: The `Bool` to pack.
-///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
-///
- mutating func pack(_ value: Bool) throws
-
-/// Pack a `Double` type value into the packer.
-///
-/// - Parameters:
-/// - value: The `Double` to pack.
-///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
-///
- mutating func pack(_ value: Double) throws
-
-/// Pack a `Float` type value into the packer.
-///
-/// - Parameters:
-/// - value: The `Float` to pack.
-///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
-///
- mutating func pack(_ value: Float) throws
-
-/// Pack a `Int` type value into the packer.
-///
-/// - Parameters:
-/// - value: The `Int` to pack.
-///
-/// - Warning: Care should be taken when packing a `Int` type value, as the
-/// size of the type varies depending on the system architecture. If the
-/// data is packed on a platform supporting 32 bit architecture, then it
-/// will be incompatible with a 64-bit architecture, and vice-versa. For
-/// maximum compatibility, use one of the explicitly sized integer types
-/// such as `Int32` or `Int64`.
+/// The requirements below are the whole of what a packer must implement: how
+/// to accept bytes, how to reserve and later fill a fixed-width slot, and what
+/// layout rules it applies. Everything a caller actually uses - packing
+/// integers, strings, collections, and ``Packable`` types, and framing spans
+/// with ``withLengthPrefix(_:_:)`` - is built on top of these in extensions,
+/// defined once and shared by every packer.
///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
+/// That split is deliberate. It keeps conformance small, it means the encoding
+/// rules exist in exactly one place rather than once per packer, and it keeps
+/// the required surface non-generic so ``AnyPacker`` can forward it.
///
- mutating func pack(_ value: Int) throws
-
-/// Pack an `Int8` type value into the packer.
+/// Note that conformance does not require any particular initializer. A packer
+/// is free to be constructed however suits it, which is what allows packers
+/// that have no byte destination at all, such as ``MeasuringPacker``.
///
-/// - Parameters:
-/// - value: The `Int8` to pack.
-///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
+public protocol Packer {
+/// The layout rules this packer applies.
///
- mutating func pack(_ value: Int8) throws
+ var configuration: PackConfiguration { get }
-/// Pack an `Int16` type value into the packer.
-///
-/// - Parameters:
-/// - value: The `Int16` to pack.
-///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
+/// The user info for storing local data associated with the current pack.
///
- mutating func pack(_ value: Int16) throws
+ var userInfo: [PackUserInfoKey: any Sendable] { get set }
-/// Pack an `Int32` type value into the packer.
+/// How many bytes have been packed so far.
///
-/// - Parameters:
-/// - value: The `Int32` to pack.
-///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
+/// This is what makes a length computable - the difference between the
+/// value before and after packing a body.
///
- mutating func pack(_ value: Int32) throws
-
-/// Pack an `Int64` type value into the packer.
-///
-/// - Parameters:
-/// - value: The `Int64` to pack.
+/// The count is logical. It advances past reserved slots and past the body
+/// of a span that is still being buffered, so it means the same thing
+/// whether or not the underlying destination can be patched.
///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
-///
- mutating func pack(_ value: Int64) throws
+ var packedCount: Int { get }
-/// Pack a `UInt` type value into the packer.
-///
-/// - Parameters:
-/// - value: The `UInt` to pack.
+/// Whether reserved slots are patched directly into output already written.
///
-/// - Warning: Care should be taken when packing a `UInt` type value, as the
-/// size of the type varies depending on the system architecture. If the
-/// data is packed on a platform supporting 32 bit architecture, then it
-/// will be incompatible with a 64-bit architecture, and vice-versa. For
-/// maximum compatibility, use one of the explicitly sized integer types
-/// such as `UInt32` or `UInt64`.
+/// When `false`, the packer buffers the body of a reserved span in memory
+/// until the slot is filled, because the output it would need to patch has
+/// already been handed off - to a stream, typically.
///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
+/// The packed bytes are identical either way, and so is the behaviour -
+/// including the order in which reserved slots may be filled. This reports a
+/// difference in peak memory only, and is not a capability a caller needs to
+/// branch on.
///
- mutating func pack(_ value: UInt) throws
+ var patchesInPlace: Bool { get }
-/// Pack a `UInt8` type value into the packer.
+/// Whether any reserved slot has yet to be filled.
///
-/// - Parameters:
-/// - value: The `UInt8` to pack.
-///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
+/// Packed output is complete only when this is `false`. Prefer calling
+/// ``finish()``, which reports an incomplete state as an error rather than
+/// leaving it to be noticed.
///
- mutating func pack(_ value: UInt8) throws
+ var hasOpenPlaceholders: Bool { get }
-/// Pack a `UInt16` type value into the packer.
-///
-/// - Parameters:
-/// - value: The `UInt16` to pack.
+/// Pack raw bytes into the packer, exactly as given.
///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
-///
- mutating func pack(_ value: UInt16) throws
-
-/// Pack a `UInt32` type value into the packer.
+/// This is the primitive every other packing operation is built from. The
+/// bytes are not reordered, framed, or interpreted.
///
/// - Parameters:
-/// - value: The `UInt32` to pack.
+/// - bytes: The bytes to pack.
///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
+/// - Throws: ``PackError`` if the destination could not accept the bytes.
///
- mutating func pack(_ value: UInt32) throws
+ mutating func write(_ bytes: UnsafeRawBufferPointer) throws
-/// Pack a `UInt64` type value into the packer.
-///
-/// - Parameters:
-/// - value: The `UInt64` to pack.
+/// Reserve a fixed number of bytes to be filled in later.
///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
+/// Use this where a value must appear before the thing that determines it -
+/// a length ahead of the body it measures, most commonly. Reserved bytes
+/// count towards ``packedCount`` immediately.
///
- mutating func pack(_ value: UInt64) throws
-
-/// Pack a `String` type value into the packer, with a specified encoding.
+/// Prefer ``withLengthPrefix(_:_:)``, which reserves and fills a length
+/// correctly, over calling this directly.
///
/// - Parameters:
-/// - value: The `String` to pack.
-/// - encoding: The string encoding used to encode the characters in the
-/// string into memory.
+/// - byteCount: The number of bytes to reserve.
///
-/// - Warning: Care should be taken when packing a `String`, to ensure the
-/// Encoding of the string will not change. For example, packing a string
-/// using `ascii` will store characters in 8 bits, whereas packing the
-/// string as `utf32` may result in up to 32 bits per character. To ensure
-/// packed strings can always be unpacked, a consistent encoding should be
-/// used.
+/// - Returns: A placeholder identifying the reserved bytes.
///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
+/// - Throws: ``PackError`` if the bytes could not be reserved.
///
- mutating func pack(_ value: String, using encoding: String.Encoding) throws
-
-/// Pack bytes stored in a `Data` object into the packer.
-///
-/// - Parameters:
-/// - value: The `Data` object containing the bytes to pack.
-///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
-///
- mutating func pack(_ data: Data) throws
+ mutating func reserve(byteCount: Int) throws -> PackerPlaceholder
-/// Pack a type conforming to ``Packable`` into the packer.
-///
-/// Types can conform to the ``Packable`` protocol, allowing the type
-/// itself to describe how its data should be packed.
+/// Fill bytes previously reserved.
///
/// - Parameters:
-/// - packable: The type conforming to ``Packable`` that should be packed.
+/// - placeholder: The placeholder identifying the reserved bytes.
+/// - bytes: The bytes to write into the reserved slot. The count must
+/// match the reserved width exactly.
///
-/// - Throws: ``PackError`` if the packer is currently not packing values.
+/// - Throws: ``PackError`` if the placeholder was not vended by this packer,
+/// has already been filled, is being filled out of order, or if the byte
+/// count does not match the width reserved.
///
- mutating func pack(_ packable: Packable) throws
+ mutating func fill(_ placeholder: PackerPlaceholder, with bytes: UnsafeRawBufferPointer) throws
}
diff --git a/Sources/Pack/Protocols/UnpackSource.swift b/Sources/Pack/Protocols/UnpackSource.swift
new file mode 100644
index 0000000..db98de1
--- /dev/null
+++ b/Sources/Pack/Protocols/UnpackSource.swift
@@ -0,0 +1,41 @@
+//
+// UnpackSource.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+/// A source that packed bytes are read from.
+///
+public protocol UnpackSource {
+/// The number of bytes consumed from this source.
+///
+ var consumedCount: Int { get }
+
+/// The number of bytes remaining, or `nil` where that cannot be known.
+///
+/// A source of indeterminate length, such as one wrapping a stream, reports
+/// `nil`.
+///
+ var remainingCount: Int? { get }
+
+/// Read bytes from the source, filling the given buffer completely.
+///
+/// - Parameters:
+/// - buffer: The buffer to fill.
+///
+/// - Throws: ``PackError`` if the source is exhausted before the buffer is
+/// filled.
+///
+ mutating func read(into buffer: UnsafeMutableRawBufferPointer) throws
+
+/// Advance past the specified number of bytes.
+///
+/// - Parameters:
+/// - byteCount: The number of bytes to skip.
+///
+/// - Throws: ``PackError`` if the source is exhausted first.
+///
+ mutating func skip(_ byteCount: Int) throws
+}
diff --git a/Sources/Pack/Protocols/Unpackable.swift b/Sources/Pack/Protocols/Unpackable.swift
index 3df031e..d6d0ea2 100644
--- a/Sources/Pack/Protocols/Unpackable.swift
+++ b/Sources/Pack/Protocols/Unpackable.swift
@@ -8,6 +8,21 @@
/// A type that can unpack itself from an external representation.
///
+/// As with ``Packable``, the unpacker is a generic parameter so that unpacking
+/// specialises. Conformances use `some`:
+///
+/// ```swift
+/// extension Color: Unpackable {
+/// init(from unpacker: inout some Unpacker) throws {
+/// self.name = try unpacker.unpack(String.self, framing: .lengthPrefixed(.uint32))
+/// self.red = try unpacker.unpack(Double.self)
+/// self.green = try unpacker.unpack(Double.self)
+/// self.blue = try unpacker.unpack(Double.self)
+/// self.alpha = try unpacker.unpack(Double.self)
+/// }
+/// }
+/// ```
+///
public protocol Unpackable {
/// Creates a new instance of this object by unpacking data using the
/// provided unpacker.
@@ -18,5 +33,5 @@ public protocol Unpackable {
/// - Throws: ``PackError`` if unpacking the data fails, or if the data
/// source is corrupted or otherwise invalid.
///
- init(from unpacker: inout Unpacker) throws
+ init(from unpacker: inout U) throws
}
diff --git a/Sources/Pack/Protocols/Unpacker.swift b/Sources/Pack/Protocols/Unpacker.swift
index c98d636..3c19516 100644
--- a/Sources/Pack/Protocols/Unpacker.swift
+++ b/Sources/Pack/Protocols/Unpacker.swift
@@ -8,261 +8,86 @@
import Foundation
-/// A type that can unpack various types from an external representation.
+/// A type that can unpack values from an external representation.
///
-public protocol Unpacker {
-/// Indicates if the unpacker is currently unpacking.
-///
-/// This is useful in cases where an object acts as both an Unpacker and a
-/// ``Packer``, and can only perform either a pack or an unpack at once.
-///
- var isUnpacking: Bool { get }
-
-/// The user info for storing local data associated with the current unpack.
-///
- var userInfo: [PackUserInfoKey: Any] { get set }
-
-/// Initialize a new Packer, reading data from the provided `Data` object.
-///
-/// - Parameters:
-/// - data: The `Data` object to unpack data from.
-///
- init(from data: Data)
-
-/// Initialize a new Packer, reading from the provided `InputStream`.
-///
-/// - Parameters:
-/// - stream: The stream to unpack data from.
-///
- init(readingFrom stream: InputStream)
-
-/// Offset the current read index by the specified amount in bytes.
-///
-/// This can be useful for skipping over certain blocks of data.
-///
-/// - Parameters:
-/// - count: The amount to offset the read index in bytes. For example, a
-/// value of 1 would offset 8 bits forward.
-///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
-///
- mutating func offset(by count: UInt) throws
-
-/// Unpack a `Bool` type value from the data source.
-///
-/// - Parameters:
-/// - type: The type of object to unpack.
-///
-/// - Returns: A `Bool` value unpacked from the data source.
+/// As with ``Packer``, the requirements here are a small non-generic core -
+/// reading bytes, skipping bytes, and bounding a span - and everything a
+/// caller uses is built on top in extensions.
///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
-///
- mutating func unpack(_ type: Bool.Type) throws -> Bool
-
-/// Unpack a `Double` type value from the data source.
-///
-/// - Parameters:
-/// - type: The type of object to unpack.
-///
-/// - Returns: A `Double` value unpacked from the data source.
-///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
-///
- mutating func unpack(_ type: Double.Type) throws -> Double
-
-/// Unpack a `Float` type value from the data source.
-///
-/// - Parameters:
-/// - type: The type of object to unpack.
-///
-/// - Returns: A `Float` value unpacked from the data source.
-///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
-///
- mutating func unpack(_ type: Float.Type) throws -> Float
-
-/// Unpack a `Int` type value from the data source.
-///
-/// - Parameters:
-/// - type: The type of object to unpack.
-///
-/// - Warning: Care should be taken when unpacking an `Int` type value, as
-/// the size of the type varies depending on the system architecture. If the
-/// data is packed on a platform supporting 32 bit architecture, then it
-/// will be incompatible with a 64-bit architecture, and vice-versa. For
-/// maximum compatibility, use one of the explicitly sized integer types
-/// such as `Int32` or `Int64`.
-///
-/// - Returns: A `Int` value unpacked from the data source.
-///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
-///
- mutating func unpack(_ type: Int.Type) throws -> Int
-
-/// Unpack a `Int8` type value from the data source.
-///
-/// - Parameters:
-/// - type: The type of object to unpack.
-///
-/// - Returns: A `Int8` value unpacked from the data source.
-///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
-///
- mutating func unpack(_ type: Int8.Type) throws -> Int8
-
-/// Unpack a `Int16` type value from the data source.
-///
-/// - Parameters:
-/// - type: The type of object to unpack.
-///
-/// - Returns: A `Int16` value unpacked from the data source.
-///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
-///
- mutating func unpack(_ type: Int16.Type) throws -> Int16
-
-/// Unpack a `Int32` type value from the data source.
-///
-/// - Parameters:
-/// - type: The type of object to unpack.
-///
-/// - Returns: A `Int32` value unpacked from the data source.
-///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
+public protocol Unpacker {
+/// The layout rules this unpacker applies.
///
- mutating func unpack(_ type: Int32.Type) throws -> Int32
+ var configuration: PackConfiguration { get }
-/// Unpack a `Int64` type value from the data source.
-///
-/// - Parameters:
-/// - type: The type of object to unpack.
-///
-/// - Returns: A `Int64` value unpacked from the data source.
-///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
+/// The user info for storing local data associated with the current unpack.
///
- mutating func unpack(_ type: Int64.Type) throws -> Int64
+ var userInfo: [PackUserInfoKey: any Sendable] { get set }
-/// Unpack a `UInt` type value from the data source.
-///
-/// - Warning: Care should be taken when unpacking an `UInt` type value, as
-/// the size of the type varies depending on the system architecture. If the
-/// data is packed on a platform supporting 32 bit architecture, then it
-/// will be incompatible with a 64-bit architecture, and vice-versa. For
-/// maximum compatibility, use one of the explicitly sized integer types
-/// such as `UInt32` or `UInt64`.
-///
-/// - Parameters:
-/// - type: The type of object to unpack.
-///
-/// - Returns: A `UInt` value unpacked from the data source.
+/// How many bytes have been consumed so far.
///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
+/// This makes a span's declared length checkable: a body that consumed more
+/// than its header declared indicates a corrupt file.
///
- mutating func unpack(_ type: UInt.Type) throws -> UInt
+ var unpackedCount: Int { get }
-/// Unpack a `UInt8` type value from the data source.c
+/// How many bytes remain available to read, or `nil` where that cannot be
+/// known.
///
-/// - Parameters:
-/// - type: The type of object to unpack.
+/// Within a limited span this reports the remainder of the span rather than
+/// the remainder of the source. It is `nil` for a source of indeterminate
+/// length, such as a stream, when no limit is in effect.
///
-/// - Returns: A `UInt8` value unpacked from the data source.
-///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
-///
- mutating func unpack(_ type: UInt8.Type) throws -> UInt8
+ var remainingCount: Int? { get }
-/// Unpack a `UInt16` type value from the data source.
-///
-/// - Parameters:
-/// - type: The type of object to unpack.
-///
-/// - Returns: A `UInt16` value unpacked from the data source.
+/// Unpack raw bytes from the source, filling the given buffer.
///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
-///
- mutating func unpack(_ type: UInt16.Type) throws -> UInt16
-
-/// Unpack a `UInt32` type value from the data source.
+/// This is the primitive every other unpacking operation is built from. The
+/// bytes are not reordered or interpreted.
///
/// - Parameters:
-/// - type: The type of object to unpack.
-///
-/// - Returns: A `UInt32` value unpacked from the data source.
+/// - buffer: The buffer to read into. It is filled completely, or the
+/// call throws.
///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
+/// - Throws: ``PackError`` if the source is exhausted, or if the read would
+/// pass the end of the innermost limited span.
///
- mutating func unpack(_ type: UInt32.Type) throws -> UInt32
+ mutating func read(into buffer: UnsafeMutableRawBufferPointer) throws
-/// Unpack a `UInt64` type value from the data source.
+/// Skip forward by the specified number of bytes.
///
/// - Parameters:
-/// - type: The type of object to unpack.
+/// - byteCount: The number of bytes to skip.
///
-/// - Returns: A `UInt64` value unpacked from the data source.
+/// - Throws: ``PackError`` if the source is exhausted, or if the skip would
+/// pass the end of the innermost limited span.
///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
-///
- mutating func unpack(_ type: UInt64.Type) throws -> UInt64
+ mutating func skip(_ byteCount: Int) throws
-/// Unpack a `String` type value from the data source, packed with the
-/// specified encoding.
-///
-/// - Warning: Care should be taken when unpacking a `String`, to ensure the
-/// Encoding of the string is the same as when it was packed. For example,
-/// packing a string using `ascii` will store characters in 8 bits, whereas
-/// packing the string as `utf32` may result in up to 32 bits per character.
-/// To ensure packed strings can always be unpacked, a consistent encoding
-/// should be used.
+/// Bound subsequent reads to a span of the given length.
///
-/// - Parameters:
-/// - type: The type of object to unpack.
-/// - encoding: The string encoding that the string was encoded with when
-/// packing.
-///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
+/// Reads are checked against the limit as they happen, so a corrupt length
+/// cannot be used to read on into whatever follows the span.
///
- mutating func unpack(_ type: String.Type, using encoding: String.Encoding) throws -> String
-
-/// Unpack bytes from the data source, without interpreting the data as a
-/// specific type. The number of bytes to read can be specified.
+/// Prefer ``withLimit(byteCount:_:)`` over calling this directly.
///
/// - Parameters:
-/// - type: The type of object to unpack.
-/// - size: The number of bytes to read from the data source.
+/// - byteCount: The length of the span, measured from the current
+/// position.
///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
+/// - Throws: ``PackError`` if the span would extend beyond an enclosing
+/// limit, or beyond the end of a source whose length is known.
///
- mutating func unpack(_ type: Data.Type, size: Int) throws -> Data
+ mutating func pushLimit(byteCount: Int) throws
-/// Unpack a type conforming to ``Unpackable`` from the data source.
-///
-/// Types can conform to the ``Unpackable`` protocol, allowing the type
-/// itself to describe how its data should be unpacked.
+/// Remove the innermost limit.
///
/// - Parameters:
-/// - type: The type of object to unpack.
-///
-/// - Returns: The ``Unpackable`` type unpacked from the data source.
+/// - skippingRemainder: Whether to advance to the end of the span before
+/// removing the limit. This is what allows a reader to accept a span
+/// containing fields it does not recognise.
///
-/// - Throws: ``PackError`` if the unpacker is currently not unpacking
-/// values, or if the end of the input source is reached.
+/// - Throws: ``PackError`` if there is no limit in effect, or if the
+/// remainder could not be skipped.
///
- mutating func unpack(_ type: T.Type) throws -> T
+ mutating func popLimit(skippingRemainder: Bool) throws
}
diff --git a/Sources/Pack/StringFraming.swift b/Sources/Pack/StringFraming.swift
new file mode 100644
index 0000000..ea0b9bb
--- /dev/null
+++ b/Sources/Pack/StringFraming.swift
@@ -0,0 +1,55 @@
+//
+// StringFraming.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+/// Describes how the extent of a packed string is recorded, so that an
+/// unpacker knows where the string ends.
+///
+/// Framing is chosen per call rather than per packer, because a single format
+/// routinely mixes all of these - a fixed-size tag, a length-prefixed name,
+/// and a null-terminated path can sit in the same structure. A packer supplies
+/// a default through its ``PackConfiguration``, and any individual field may
+/// override it.
+///
+public enum StringFraming: Hashable, Sendable {
+/// The number of bytes of string data is written immediately before the
+/// string, as an unsigned integer of the given width.
+///
+/// - Parameters:
+/// - width: The width of the unsigned integer recording the byte count.
+///
+ case lengthPrefixed(IntegerWidth)
+
+/// A single zero byte is written after the string data.
+///
+/// - Warning: Only `ascii` and `utf8` encodings may be null terminated, as
+/// other encodings can legitimately contain zero bytes within a character.
+/// Packing a string that itself contains a null character also fails, as the
+/// result could not be unpacked.
+///
+ case nullTerminated
+
+/// The string occupies a fixed number of bytes, padded to that width.
+///
+/// This is the framing used for tags and other fixed-layout identifiers.
+/// String data longer than `byteCount` is rejected rather than truncated.
+///
+/// - Parameters:
+/// - byteCount: The exact number of bytes occupied by the field.
+/// - padding: The byte used to pad string data shorter than `byteCount`.
+/// Trailing padding bytes are stripped when unpacking.
+///
+ case fixed(byteCount: Int, padding: UInt8 = 0)
+
+/// The string occupies the remainder of the input.
+///
+/// - Warning: This can only be unpacked as the final value in a source, or
+/// as the final value within a limited span. It records nothing, so nothing
+/// may follow it.
+///
+ case untilEnd
+}
diff --git a/Sources/Pack/Types/Bool+Packed.swift b/Sources/Pack/Types/Bool+Packed.swift
index ca1902b..221dbee 100644
--- a/Sources/Pack/Types/Bool+Packed.swift
+++ b/Sources/Pack/Types/Bool+Packed.swift
@@ -7,11 +7,11 @@
//
extension Bool: Packed {
- public init(from unpacker: inout Unpacker) throws {
+ public init(from unpacker: inout some Unpacker) throws {
self = try unpacker.unpack(Bool.self)
}
-
- public func pack(to packer: inout Packer) throws {
+
+ public func pack(to packer: inout some Packer) throws {
try packer.pack(self)
}
}
diff --git a/Sources/Pack/Types/Double+Packed.swift b/Sources/Pack/Types/Double+Packed.swift
index 749d549..d71304a 100644
--- a/Sources/Pack/Types/Double+Packed.swift
+++ b/Sources/Pack/Types/Double+Packed.swift
@@ -7,11 +7,11 @@
//
extension Double: Packed {
- public init(from unpacker: inout Unpacker) throws {
+ public init(from unpacker: inout some Unpacker) throws {
self = try unpacker.unpack(Double.self)
}
-
- public func pack(to packer: inout Packer) throws {
+
+ public func pack(to packer: inout some Packer) throws {
try packer.pack(self)
}
}
diff --git a/Sources/Pack/Types/Float+Packed.swift b/Sources/Pack/Types/Float+Packed.swift
index 26ba462..6dbb46c 100644
--- a/Sources/Pack/Types/Float+Packed.swift
+++ b/Sources/Pack/Types/Float+Packed.swift
@@ -7,11 +7,11 @@
//
extension Float: Packed {
- public init(from unpacker: inout Unpacker) throws {
+ public init(from unpacker: inout some Unpacker) throws {
self = try unpacker.unpack(Float.self)
}
-
- public func pack(to packer: inout Packer) throws {
+
+ public func pack(to packer: inout some Packer) throws {
try packer.pack(self)
}
}
diff --git a/Sources/Pack/Types/Int+Packed.swift b/Sources/Pack/Types/Int+Packed.swift
deleted file mode 100644
index b7eeec6..0000000
--- a/Sources/Pack/Types/Int+Packed.swift
+++ /dev/null
@@ -1,17 +0,0 @@
-//
-// Int+Packed.swift
-// Pack
-//
-// Created by Matt Cox on 11/02/2022.
-// Copyright © 2024 Matt Cox. All rights reserved.
-//
-
-extension Int: Packed {
- public init(from unpacker: inout Unpacker) throws {
- self = try unpacker.unpack(Int.self)
- }
-
- public func pack(to packer: inout Packer) throws {
- try packer.pack(self)
- }
-}
diff --git a/Sources/Pack/Types/Int16+Packed.swift b/Sources/Pack/Types/Int16+Packed.swift
index 9289c76..1b05e51 100644
--- a/Sources/Pack/Types/Int16+Packed.swift
+++ b/Sources/Pack/Types/Int16+Packed.swift
@@ -7,11 +7,11 @@
//
extension Int16: Packed {
- public init(from unpacker: inout Unpacker) throws {
+ public init(from unpacker: inout some Unpacker) throws {
self = try unpacker.unpack(Int16.self)
}
-
- public func pack(to packer: inout Packer) throws {
+
+ public func pack(to packer: inout some Packer) throws {
try packer.pack(self)
}
}
diff --git a/Sources/Pack/Types/Int32+Packed.swift b/Sources/Pack/Types/Int32+Packed.swift
index 53b902b..5b7c86d 100644
--- a/Sources/Pack/Types/Int32+Packed.swift
+++ b/Sources/Pack/Types/Int32+Packed.swift
@@ -7,11 +7,11 @@
//
extension Int32: Packed {
- public init(from unpacker: inout Unpacker) throws {
+ public init(from unpacker: inout some Unpacker) throws {
self = try unpacker.unpack(Int32.self)
}
-
- public func pack(to packer: inout Packer) throws {
+
+ public func pack(to packer: inout some Packer) throws {
try packer.pack(self)
}
}
diff --git a/Sources/Pack/Types/Int64+Packed.swift b/Sources/Pack/Types/Int64+Packed.swift
index 13cd52c..223f478 100644
--- a/Sources/Pack/Types/Int64+Packed.swift
+++ b/Sources/Pack/Types/Int64+Packed.swift
@@ -7,11 +7,11 @@
//
extension Int64: Packed {
- public init(from unpacker: inout Unpacker) throws {
+ public init(from unpacker: inout some Unpacker) throws {
self = try unpacker.unpack(Int64.self)
}
-
- public func pack(to packer: inout Packer) throws {
+
+ public func pack(to packer: inout some Packer) throws {
try packer.pack(self)
}
}
diff --git a/Sources/Pack/Types/Int8+Packed.swift b/Sources/Pack/Types/Int8+Packed.swift
index 8074f42..13f125c 100644
--- a/Sources/Pack/Types/Int8+Packed.swift
+++ b/Sources/Pack/Types/Int8+Packed.swift
@@ -7,11 +7,11 @@
//
extension Int8: Packed {
- public init(from unpacker: inout Unpacker) throws {
+ public init(from unpacker: inout some Unpacker) throws {
self = try unpacker.unpack(Int8.self)
}
-
- public func pack(to packer: inout Packer) throws {
+
+ public func pack(to packer: inout some Packer) throws {
try packer.pack(self)
}
}
diff --git a/Sources/Pack/Types/String+Packed.swift b/Sources/Pack/Types/String+Packed.swift
index 1ff781b..1d41542 100644
--- a/Sources/Pack/Types/String+Packed.swift
+++ b/Sources/Pack/Types/String+Packed.swift
@@ -7,11 +7,11 @@
//
extension String: Packed {
- public init(from unpacker: inout Unpacker) throws {
- self = try unpacker.unpack(String.self, using: .utf8)
+ public init(from unpacker: inout some Unpacker) throws {
+ self = try unpacker.unpack(String.self)
}
-
- public func pack(to packer: inout Packer) throws {
- try packer.pack(self, using: .utf8)
+
+ public func pack(to packer: inout some Packer) throws {
+ try packer.pack(self)
}
}
diff --git a/Sources/Pack/Types/UInt+Packed.swift b/Sources/Pack/Types/UInt+Packed.swift
deleted file mode 100644
index 8fe5104..0000000
--- a/Sources/Pack/Types/UInt+Packed.swift
+++ /dev/null
@@ -1,17 +0,0 @@
-//
-// UInt+Packed.swift
-// Pack
-//
-// Created by Matt Cox on 11/02/2022.
-// Copyright © 2024 Matt Cox. All rights reserved.
-//
-
-extension UInt: Packed {
- public init(from unpacker: inout Unpacker) throws {
- self = try unpacker.unpack(UInt.self)
- }
-
- public func pack(to packer: inout Packer) throws {
- try packer.pack(self)
- }
-}
diff --git a/Sources/Pack/Types/UInt16+Packed.swift b/Sources/Pack/Types/UInt16+Packed.swift
index 3734ea1..abb48cb 100644
--- a/Sources/Pack/Types/UInt16+Packed.swift
+++ b/Sources/Pack/Types/UInt16+Packed.swift
@@ -7,11 +7,11 @@
//
extension UInt16: Packed {
- public init(from unpacker: inout Unpacker) throws {
+ public init(from unpacker: inout some Unpacker) throws {
self = try unpacker.unpack(UInt16.self)
}
-
- public func pack(to packer: inout Packer) throws {
+
+ public func pack(to packer: inout some Packer) throws {
try packer.pack(self)
}
}
diff --git a/Sources/Pack/Types/UInt32+Packed.swift b/Sources/Pack/Types/UInt32+Packed.swift
index 6461f05..f8fb54d 100644
--- a/Sources/Pack/Types/UInt32+Packed.swift
+++ b/Sources/Pack/Types/UInt32+Packed.swift
@@ -7,11 +7,11 @@
//
extension UInt32: Packed {
- public init(from unpacker: inout Unpacker) throws {
+ public init(from unpacker: inout some Unpacker) throws {
self = try unpacker.unpack(UInt32.self)
}
-
- public func pack(to packer: inout Packer) throws {
+
+ public func pack(to packer: inout some Packer) throws {
try packer.pack(self)
}
}
diff --git a/Sources/Pack/Types/UInt64+Packed.swift b/Sources/Pack/Types/UInt64+Packed.swift
index e4a4a70..ed5750c 100644
--- a/Sources/Pack/Types/UInt64+Packed.swift
+++ b/Sources/Pack/Types/UInt64+Packed.swift
@@ -7,11 +7,11 @@
//
extension UInt64: Packed {
- public init(from unpacker: inout Unpacker) throws {
+ public init(from unpacker: inout some Unpacker) throws {
self = try unpacker.unpack(UInt64.self)
}
-
- public func pack(to packer: inout Packer) throws {
+
+ public func pack(to packer: inout some Packer) throws {
try packer.pack(self)
}
}
diff --git a/Sources/Pack/Types/UInt8+Packed.swift b/Sources/Pack/Types/UInt8+Packed.swift
index f090101..a00f27d 100644
--- a/Sources/Pack/Types/UInt8+Packed.swift
+++ b/Sources/Pack/Types/UInt8+Packed.swift
@@ -7,11 +7,11 @@
//
extension UInt8: Packed {
- public init(from unpacker: inout Unpacker) throws {
+ public init(from unpacker: inout some Unpacker) throws {
self = try unpacker.unpack(UInt8.self)
}
-
- public func pack(to packer: inout Packer) throws {
+
+ public func pack(to packer: inout some Packer) throws {
try packer.pack(self)
}
}
diff --git a/Sources/Pack/Unpacker+Unpacking.swift b/Sources/Pack/Unpacker+Unpacking.swift
new file mode 100644
index 0000000..c051f88
--- /dev/null
+++ b/Sources/Pack/Unpacker+Unpacking.swift
@@ -0,0 +1,485 @@
+//
+// Unpacker+Unpacking.swift
+// Pack
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+
+// The unpacking API, mirroring `Packer+Packing`. As there, none of this is a
+// protocol requirement.
+//
+extension Unpacker {
+/// Unpack a fixed width integer from the source.
+///
+/// - Parameters:
+/// - type: The type of integer to unpack.
+///
+/// - Returns: The unpacked value.
+///
+/// - Throws: ``PackError`` if the source is exhausted, or the read would
+/// pass the end of the innermost limited span.
+///
+ public mutating func unpack(_ type: T.Type) throws -> T {
+ try unpackInteger(type)
+ }
+
+// The implementation behind every integer entry point. The public overloads
+// forward here rather than to each other, so that the concrete overloads below
+// cannot recurse into themselves.
+//
+ private mutating func unpackInteger(_ type: T.Type) throws -> T {
+ var value = T.zero
+ try withUnsafeMutableBytes(of: &value) {
+ try read(into: $0)
+ }
+
+ return configuration.byteOrder == .littleEndian ? T(littleEndian: value) : T(bigEndian: value)
+ }
+
+// Concrete overloads settling the ambiguity between the FixedWidthInteger and
+// Unpackable entry points, as described in `Packer+Packing`.
+//
+ public mutating func unpack(_ type: Int8.Type) throws -> Int8 {
+ try unpackInteger(type)
+ }
+
+ public mutating func unpack(_ type: Int16.Type) throws -> Int16 {
+ try unpackInteger(type)
+ }
+
+ public mutating func unpack(_ type: Int32.Type) throws -> Int32 {
+ try unpackInteger(type)
+ }
+
+ public mutating func unpack(_ type: Int64.Type) throws -> Int64 {
+ try unpackInteger(type)
+ }
+
+ public mutating func unpack(_ type: UInt8.Type) throws -> UInt8 {
+ try unpackInteger(type)
+ }
+
+ public mutating func unpack(_ type: UInt16.Type) throws -> UInt16 {
+ try unpackInteger(type)
+ }
+
+ public mutating func unpack(_ type: UInt32.Type) throws -> UInt32 {
+ try unpackInteger(type)
+ }
+
+ public mutating func unpack(_ type: UInt64.Type) throws -> UInt64 {
+ try unpackInteger(type)
+ }
+
+/// Unpack a `Bool` from the source.
+///
+/// - Parameters:
+/// - type: The type of object to unpack.
+///
+/// - Returns: The unpacked value.
+///
+/// - Throws: ``PackError`` if the source is exhausted, or the read would
+/// pass the end of the innermost limited span.
+///
+ public mutating func unpack(_ type: Bool.Type) throws -> Bool {
+ try unpack(UInt8.self) != 0
+ }
+
+/// Unpack a `Float` from the source.
+///
+/// - Parameters:
+/// - type: The type of object to unpack.
+///
+/// - Returns: The unpacked value.
+///
+/// - Throws: ``PackError`` if the source is exhausted, or the read would
+/// pass the end of the innermost limited span.
+///
+ public mutating func unpack(_ type: Float.Type) throws -> Float {
+ Float(bitPattern: try unpack(UInt32.self))
+ }
+
+/// Unpack a `Double` from the source.
+///
+/// - Parameters:
+/// - type: The type of object to unpack.
+///
+/// - Returns: The unpacked value.
+///
+/// - Throws: ``PackError`` if the source is exhausted, or the read would
+/// pass the end of the innermost limited span.
+///
+ public mutating func unpack(_ type: Double.Type) throws -> Double {
+ Double(bitPattern: try unpack(UInt64.self))
+ }
+
+/// Unpack raw bytes from the source, without interpreting them.
+///
+/// - Parameters:
+/// - type: The type of object to unpack.
+/// - byteCount: The number of bytes to read.
+///
+/// - Returns: The unpacked bytes.
+///
+/// - Throws: ``PackError`` if the source is exhausted, or the read would
+/// pass the end of the innermost limited span.
+///
+ public mutating func unpack(_ type: Data.Type, byteCount: Int) throws -> Data {
+ guard byteCount >= 0 else {
+ throw PackError(
+ .valueOutOfRange,
+ offset: unpackedCount,
+ reason: "Cannot read a negative number of bytes"
+ )
+ }
+
+ guard byteCount > 0 else {
+ return Data()
+ }
+
+ var data = Data(count: byteCount)
+ try data.withUnsafeMutableBytes {
+ try read(into: $0)
+ }
+
+ return data
+ }
+
+/// Unpack a type conforming to ``Unpackable`` from the source.
+///
+/// - Parameters:
+/// - type: The type of object to unpack.
+///
+/// - Returns: The unpacked value.
+///
+/// - Throws: ``PackError`` if the value could not be unpacked.
+///
+ public mutating func unpack(_ type: T.Type) throws -> T {
+ try T(from: &self)
+ }
+
+/// Unpack a `String` from the source.
+///
+/// - Parameters:
+/// - type: The type of object to unpack.
+/// - encoding: The encoding the string was packed with. Defaults to the
+/// encoding named by the unpacker's configuration.
+/// - framing: How the extent of the string was recorded. Defaults to the
+/// framing named by the unpacker's configuration.
+///
+/// - Returns: The unpacked value.
+///
+/// - Throws: ``PackError`` if the bytes are not valid in the given encoding,
+/// if the encoding and framing are incompatible, or if the source is
+/// exhausted.
+///
+ public mutating func unpack(
+ _ type: String.Type,
+ using encoding: String.Encoding? = nil,
+ framing: StringFraming? = nil
+ ) throws -> String {
+ let encoding = encoding ?? configuration.defaultStringEncoding
+ let framing = framing ?? configuration.defaultStringFraming
+ let offset = unpackedCount
+
+ var data: Data
+ switch framing {
+ case .lengthPrefixed(let width):
+ let byteCount = try unpack(count: width)
+ data = try unpack(Data.self, byteCount: byteCount)
+
+ case .nullTerminated:
+ guard encoding == .ascii || encoding == .utf8 else {
+ throw PackError(
+ .unsupportedEncoding,
+ offset: offset,
+ reason: "Only ascii and utf8 strings may be null terminated"
+ )
+ }
+
+ data = Data()
+ while true {
+ let byte = try unpack(UInt8.self)
+ if byte == .zero {
+ break
+ }
+
+ data.append(byte)
+ }
+
+ case .fixed(let byteCount, let padding):
+ data = try unpack(Data.self, byteCount: byteCount)
+ while data.last == padding {
+ data.removeLast()
+ }
+
+ case .untilEnd:
+ if let remainingCount {
+ data = try unpack(Data.self, byteCount: remainingCount)
+ }
+ else {
+ data = Data()
+ while true {
+ do {
+ data.append(try unpack(UInt8.self))
+ }
+ catch let error as PackError where error.code == .endOfInput {
+ break
+ }
+ }
+ }
+ }
+
+ guard let string = String(data: data, encoding: encoding) else {
+ throw PackError(
+ .invalidString,
+ offset: offset,
+ reason: "The bytes are not valid in the requested encoding"
+ )
+ }
+
+ return string
+ }
+
+/// Unpack a number of fixed width integers from the source.
+///
+/// - Parameters:
+/// - type: The type of integer to unpack.
+/// - count: The number of elements to unpack.
+///
+/// - Returns: The unpacked values, in order.
+///
+/// - Throws: ``PackError`` if the source is exhausted, or the read would
+/// pass the end of the innermost limited span.
+///
+ public mutating func unpack(_ type: T.Type, count: Int) throws -> [T] {
+ guard count >= 0 else {
+ throw PackError(
+ .valueOutOfRange,
+ offset: unpackedCount,
+ reason: "Cannot read a negative number of elements"
+ )
+ }
+
+ guard count > 0 else {
+ return []
+ }
+
+ var values = [T](repeating: .zero, count: count)
+ try values.withUnsafeMutableBytes {
+ try read(into: $0)
+ }
+
+ if MemoryLayout.size > 1 && configuration.byteOrder != ByteOrder.system {
+ for index in values.indices {
+ values[index] = configuration.byteOrder == .littleEndian
+ ? T(littleEndian: values[index])
+ : T(bigEndian: values[index])
+ }
+ }
+
+ return values
+ }
+
+/// Unpack a number of `Float` values from the source.
+///
+/// - Parameters:
+/// - type: The type of object to unpack.
+/// - count: The number of elements to unpack.
+///
+/// - Returns: The unpacked values, in order.
+///
+/// - Throws: ``PackError`` if the source is exhausted, or the read would
+/// pass the end of the innermost limited span.
+///
+ public mutating func unpack(_ type: Float.Type, count: Int) throws -> [Float] {
+ try unpack(UInt32.self, count: count).map {
+ Float(bitPattern: $0)
+ }
+ }
+
+/// Unpack a number of `Double` values from the source.
+///
+/// - Parameters:
+/// - type: The type of object to unpack.
+/// - count: The number of elements to unpack.
+///
+/// - Returns: The unpacked values, in order.
+///
+/// - Throws: ``PackError`` if the source is exhausted, or the read would
+/// pass the end of the innermost limited span.
+///
+ public mutating func unpack(_ type: Double.Type, count: Int) throws -> [Double] {
+ try unpack(UInt64.self, count: count).map {
+ Double(bitPattern: $0)
+ }
+ }
+
+/// Unpack fixed width integers preceded by a count.
+///
+/// - Parameters:
+/// - type: The type of integer to unpack.
+/// - width: The width of the unsigned integer recording the element count.
+///
+/// - Returns: The unpacked values, in order.
+///
+/// - Throws: ``PackError`` if the source is exhausted, or the read would
+/// pass the end of the innermost limited span.
+///
+ public mutating func unpack(
+ _ type: T.Type,
+ countPrefixedBy width: IntegerWidth
+ ) throws -> [T] {
+ try unpack(type, count: try unpack(count: width))
+ }
+
+/// Unpack `Float` values preceded by a count.
+///
+/// - Parameters:
+/// - type: The type of object to unpack.
+/// - width: The width of the unsigned integer recording the element count.
+///
+/// - Returns: The unpacked values, in order.
+///
+/// - Throws: ``PackError`` if the source is exhausted, or the read would
+/// pass the end of the innermost limited span.
+///
+ public mutating func unpack(
+ _ type: Float.Type,
+ countPrefixedBy width: IntegerWidth
+ ) throws -> [Float] {
+ try unpack(type, count: try unpack(count: width))
+ }
+
+/// Unpack `Double` values preceded by a count.
+///
+/// - Parameters:
+/// - type: The type of object to unpack.
+/// - width: The width of the unsigned integer recording the element count.
+///
+/// - Returns: The unpacked values, in order.
+///
+/// - Throws: ``PackError`` if the source is exhausted, or the read would
+/// pass the end of the innermost limited span.
+///
+ public mutating func unpack(
+ _ type: Double.Type,
+ countPrefixedBy width: IntegerWidth
+ ) throws -> [Double] {
+ try unpack(type, count: try unpack(count: width))
+ }
+
+/// Unpack a span of a known length, ignoring anything within it that is not
+/// read.
+///
+/// This is what allows a reader to accept a span written by a newer writer
+/// that appended fields it does not recognise: the fields it knows about are
+/// read, and the remainder is skipped.
+///
+/// ```swift
+/// let length = try unpacker.unpack(UInt64.self)
+/// try unpacker.withLimit(byteCount: Int(length)) { unpacker in
+/// // read the fields this version knows about
+/// }
+/// ```
+///
+/// Reads inside the span are bounded as they happen, so a corrupt length
+/// cannot be used to read on into whatever follows.
+///
+/// - Parameters:
+/// - byteCount: The length of the span, measured from the current
+/// position.
+/// - body: A closure unpacking the contents of the span.
+///
+/// - Returns: The value returned by `body`.
+///
+/// - Throws: ``PackError`` if the span extends beyond an enclosing limit or
+/// the end of the source, or if unpacking fails.
+///
+ @discardableResult
+ public mutating func withLimit(
+ byteCount: Int,
+ _ body: (inout Self) throws -> R
+ ) throws -> R {
+ try pushLimit(byteCount: byteCount)
+
+ let result: R
+ do {
+ result = try body(&self)
+ }
+ catch {
+ // The limit is removed without skipping, so that the position
+ // reflects where the failure occurred.
+ //
+ try? popLimit(skippingRemainder: false)
+ throw error
+ }
+
+ try popLimit(skippingRemainder: true)
+ return result
+ }
+
+// Unpacks a count written at the given width.
+//
+ private mutating func unpack(count width: IntegerWidth) throws -> Int {
+ let value: UInt64
+ switch width {
+ case .uint8:
+ value = UInt64(try unpack(UInt8.self))
+ case .uint16:
+ value = UInt64(try unpack(UInt16.self))
+ case .uint32:
+ value = UInt64(try unpack(UInt32.self))
+ case .uint64:
+ value = try unpack(UInt64.self)
+ }
+
+ guard value <= UInt64(Int.max) else {
+ throw PackError(
+ .valueOutOfRange,
+ offset: unpackedCount,
+ reason: "A count of \(value) cannot be represented on this platform"
+ )
+ }
+
+ return Int(value)
+ }
+}
+
+// As with packing, architecture dependent integers are a compile error rather
+// than a documented hazard. See `Packer+Packing` for how the block works.
+//
+extension Unpacker {
+ @available(*, unavailable, message: "Int is architecture-dependent and would read a file written only by a machine of matching width. Widen explicitly: Int(try unpack(Int64.self))")
+ public mutating func unpack(_ type: Int.Type) throws -> Int {
+ fatalError("unavailable")
+ }
+
+ @available(*, unavailable, message: "UInt is architecture-dependent and would read a file written only by a machine of matching width. Widen explicitly: UInt(try unpack(UInt64.self))")
+ public mutating func unpack(_ type: UInt.Type) throws -> UInt {
+ fatalError("unavailable")
+ }
+
+ @available(*, unavailable, message: "Int is architecture-dependent and would read a file written only by a machine of matching width. Read a sized type and map: try unpack(Int64.self, count: count).map(Int.init)")
+ public mutating func unpack(_ type: Int.Type, count: Int) throws -> [Int] {
+ fatalError("unavailable")
+ }
+
+ @available(*, unavailable, message: "UInt is architecture-dependent and would read a file written only by a machine of matching width. Read a sized type and map: try unpack(UInt64.self, count: count).map(UInt.init)")
+ public mutating func unpack(_ type: UInt.Type, count: Int) throws -> [UInt] {
+ fatalError("unavailable")
+ }
+
+ @available(*, unavailable, message: "Int is architecture-dependent and would read a file written only by a machine of matching width. Read a sized type and map: try unpack(Int64.self, countPrefixedBy: width).map(Int.init)")
+ public mutating func unpack(_ type: Int.Type, countPrefixedBy width: IntegerWidth) throws -> [Int] {
+ fatalError("unavailable")
+ }
+
+ @available(*, unavailable, message: "UInt is architecture-dependent and would read a file written only by a machine of matching width. Read a sized type and map: try unpack(UInt64.self, countPrefixedBy: width).map(UInt.init)")
+ public mutating func unpack(_ type: UInt.Type, countPrefixedBy width: IntegerWidth) throws -> [UInt] {
+ fatalError("unavailable")
+ }
+}
diff --git a/Tests/PackTests/BinaryPackTests_bigEndian.swift b/Tests/PackTests/BinaryPackTests_bigEndian.swift
deleted file mode 100644
index 58a287f..0000000
--- a/Tests/PackTests/BinaryPackTests_bigEndian.swift
+++ /dev/null
@@ -1,996 +0,0 @@
-//
-// BinaryPackTests_bigEndian.swift
-// PackTests
-//
-// Created by Matt Cox on 13/02/2022.
-// Copyright © 2024 Matt Cox. All rights reserved.
-//
-
-import XCTest
-@testable import Pack
-import Foundation
-
-final class BinaryPackTests_bigEndian: XCTestCase {
- private let byteOrder: ByteOrder = .bigEndian
-
- private func packer(options: BinaryPack.Options = BinaryPack.defaultOptions) -> BinaryPack {
- BinaryPack(options: options, byteOrder: self.byteOrder)
- }
-
- private func packer(destination: OutputStream, options: BinaryPack.Options = BinaryPack.defaultOptions) -> BinaryPack {
- BinaryPack(writingTo: destination, options: options, byteOrder: self.byteOrder)
- }
-
- private func unpacker(source: Data, options: BinaryPack.Options = BinaryPack.defaultOptions) -> BinaryPack {
- BinaryPack(from: source, options: options, byteOrder: self.byteOrder)
- }
-
- private func unpacker(source: InputStream, options: BinaryPack.Options = BinaryPack.defaultOptions) -> BinaryPack {
- BinaryPack(readingFrom: source, options: options, byteOrder: self.byteOrder)
- }
-
- private func withOutputStream(size: Int = 1024, perform: (OutputStream) throws -> Void) rethrows -> Data {
- var buffer = ContiguousArray(unsafeUninitializedCapacity: size) { pointer, initialized in
- for i in 0.. Void) rethrows {
- let inputStream = InputStream(data: data)
- inputStream.open()
- defer {
- inputStream.close()
- }
-
- try perform(inputStream)
- }
-
- private func noisyData(_ byteCount: Int) -> Data {
- var data = Data()
- for _ in 0...size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Bool into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_Bool() throws {
- let value: Bool = true
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Bool = false
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Double into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_Double() throws {
- let value = Double.random(in: 0...Double.greatestFiniteMagnitude)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Double into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_Double() throws {
- let value = Double.random(in: 0...Double.greatestFiniteMagnitude)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Double = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Float into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_Float() throws {
- let value = Float.random(in: 0...Float.greatestFiniteMagnitude)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Float into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_Float() throws {
- let value = Float.random(in: 0...Float.greatestFiniteMagnitude)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Float = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_Int() throws {
- let value = Int.random(in: Int.min...Int.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_Int() throws {
- let value = Int.random(in: Int.min...Int.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Int = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int8 into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_Int8() throws {
- let value = Int8.random(in: Int8.min...Int8.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int8 into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_Int8() throws {
- let value = Int8.random(in: Int8.min...Int8.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Int8 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int16 into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_Int16() throws {
- let value = Int16.random(in: Int16.min...Int16.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int16 into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_Int16() throws {
- let value = Int16.random(in: Int16.min...Int16.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Int16 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int32 into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_Int32() throws {
- let value = Int32.random(in: Int32.min...Int32.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int32 into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_Int32() throws {
- let value = Int32.random(in: Int32.min...Int32.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Int32 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int64 into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_Int64() throws {
- let value = Int64.random(in: Int64.min...Int64.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int64 into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_Int64() throws {
- let value = Int64.random(in: Int64.min...Int64.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Int64 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_UInt() throws {
- let value = UInt.random(in: UInt.min...UInt.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_UInt() throws {
- let value = UInt.random(in: UInt.min...UInt.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: UInt = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt8 into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_UInt8() throws {
- let value = UInt8.random(in: UInt8.min...UInt8.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt8 into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_UInt8() throws {
- let value = UInt8.random(in: UInt8.min...UInt8.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: UInt8 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt16 into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_UInt16() throws {
- let value = UInt16.random(in: UInt16.min...UInt16.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt16 into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_UInt16() throws {
- let value = UInt16.random(in: UInt16.min...UInt16.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: UInt16 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt32 into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_UInt32() throws {
- let value = UInt32.random(in: UInt32.min...UInt32.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt32 into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_UInt32() throws {
- let value = UInt32.random(in: UInt32.min...UInt32.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: UInt32 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt64 into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_UInt64() throws {
- let value = UInt64.random(in: UInt64.min...UInt64.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt64 into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_UInt64() throws {
- let value = UInt64.random(in: UInt64.min...UInt64.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: UInt64 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Data object into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_Data() throws {
- let value = self.noisyData(512)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == value.count, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self, size: value.count)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Data object into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_Data() throws {
- let value = self.noisyData(512)
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Data = Data()
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self, size: value.count)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with ascii encoding
-/// and the string prepended with size.
-///
- func test_packAndUnpack_toData_bigEndian_String_ascii_prependedWithSize() throws {
- let value = "I am a test string!"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .ascii)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .ascii)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with ascii encoding.
-///
- func test_packAndUnpack_toStream_bigEndian_String_ascii_prependedWithSize() throws {
- let value = "I am a test string!"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .ascii)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .ascii)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with ascii encoding
-/// and the string null terminated.
-///
- func test_packAndUnpack_toData_bigEndian_String_ascii_nullTerminated() throws {
- let value = "I am a test string!"
-
- let packer = self.packer(options: .stringsNullTerminated)
- try packer.pack(value, using: .ascii)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsNullTerminated)
- let result = try unpacker.unpack(type(of: value).self, using: .ascii)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with ascii encoding.
-///
- func test_packAndUnpack_toStream_bigEndian_String_ascii_nullTerminated() throws {
- let value = "I am a test string!"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsNullTerminated)
- try packer.pack(value, using: .ascii)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsNullTerminated)
- result = try unpacker.unpack(type(of: value).self, using: .ascii)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf8 encoding
-/// and the string prepended with size.
-///
- func test_packAndUnpack_toData_bigEndian_String_utf8_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf8)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf8)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf8 encoding.
-///
- func test_packAndUnpack_toStream_bigEndian_String_utf8_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf8)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf8)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf8 encoding
-/// and the string null terminated.
-///
- func test_packAndUnpack_toData_bigEndian_String_utf8_nullTerminated() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsNullTerminated)
- try packer.pack(value, using: .utf8)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsNullTerminated)
- let result = try unpacker.unpack(type(of: value).self, using: .utf8)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf8 encoding.
-///
- func test_packAndUnpack_toStream_bigEndian_String_utf8_nullTerminated() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsNullTerminated)
- try packer.pack(value, using: .utf8)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsNullTerminated)
- result = try unpacker.unpack(type(of: value).self, using: .utf8)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf16 encoding
-/// and the string prepended with size.
-///
- func test_packAndUnpack_toData_bigEndian_String_utf16_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf16)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf16)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf16 encoding.
-///
- func test_packAndUnpack_toStream_bigEndian_String_utf16_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf16)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf16)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf16BigEndian
-/// encoding and the string prepended with size.
-///
- func test_packAndUnpack_toData_bigEndian_String_utf16BigEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf16BigEndian)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf16BigEndian)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf16BigEndian encoding.
-///
- func test_packAndUnpack_toStream_bigEndian_String_utf16BigEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf16BigEndian)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf16BigEndian)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf16LittleEndian
-/// encoding and the string prepended with size.
-///
- func test_packAndUnpack_toData_bigEndian_String_utf16LittleEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf16LittleEndian)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf16LittleEndian)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf16LittleEndian encoding.
-///
- func test_packAndUnpack_toStream_bigEndian_String_utf16LittleEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf16LittleEndian)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf16LittleEndian)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf32 encoding
-/// and the string prepended with size.
-///
- func test_packAndUnpack_toData_bigEndian_String_utf32_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf32)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf32)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf32 encoding.
-///
- func test_packAndUnpack_toStream_bigEndian_String_utf32_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf32)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf32)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf32BigEndian
-/// encoding and the string prepended with size.
-///
- func test_packAndUnpack_toData_bigEndian_String_utf32BigEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf32BigEndian)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf32BigEndian)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf32BigEndian encoding.
-///
- func test_packAndUnpack_toStream_bigEndian_String_utf32BigEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf32BigEndian)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf32BigEndian)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf32LittleEndian
-/// encoding and the string prepended with size.
-///
- func test_packAndUnpack_toData_bigEndian_String_utf32LittleEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf32LittleEndian)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf32LittleEndian)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf32LittleEndian encoding.
-///
- func test_packAndUnpack_toStream_bigEndian_String_utf32LittleEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf32LittleEndian)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf32LittleEndian)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing mixed types into a data object.
-///
- func test_packAndUnpack_toData_bigEndian_mixed() throws {
- let intValue = 1234
- let doubleValue = 678.987654321
- let stringValue = "I am a test string! 仮 🌙 Ok"
- let floatValue: Float = 0.7728
-
- let packer = self.packer()
-
- try packer.pack(intValue)
- try packer.pack(doubleValue)
- try packer.pack(stringValue, using: .utf8)
- try packer.pack(floatValue)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data)
-
- let intResult = try unpacker.unpack(type(of: intValue).self)
- let doubleResult = try unpacker.unpack(type(of: doubleValue).self)
- let stringResult = try unpacker.unpack(type(of: stringValue).self, using: .utf8)
- let floatResult = try unpacker.unpack(type(of: floatValue).self)
-
- XCTAssertTrue(intValue == intResult, "The unpacked data does not match the original value.")
- XCTAssertTrue(doubleValue == doubleResult, "The unpacked data does not match the original value.")
- XCTAssertTrue(stringValue == stringResult, "The unpacked data does not match the original value.")
- XCTAssertTrue(floatValue == floatResult, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing mixed types into a stream.
-///
- func test_packAndUnpack_toStream_bigEndian_mixed() throws {
- let intValue = 1234
- let doubleValue = 678.987654321
- let stringValue = "I am a test string! 仮 🌙 Ok"
- let floatValue: Float = 0.7728
-
- let data = try withOutputStream(size: 1024) {
- let packer = self.packer(destination: $0)
-
- try packer.pack(intValue)
- try packer.pack(doubleValue)
- try packer.pack(stringValue, using: .utf8)
- try packer.pack(floatValue)
- }
-
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
-
- let intResult = try unpacker.unpack(type(of: intValue).self)
- let doubleResult = try unpacker.unpack(type(of: doubleValue).self)
- let stringResult = try unpacker.unpack(type(of: stringValue).self, using: .utf8)
- let floatResult = try unpacker.unpack(type(of: floatValue).self)
-
- XCTAssertTrue(intValue == intResult, "The unpacked data does not match the original value.")
- XCTAssertTrue(doubleValue == doubleResult, "The unpacked data does not match the original value.")
- XCTAssertTrue(stringValue == stringResult, "The unpacked data does not match the original value.")
- XCTAssertTrue(floatValue == floatResult, "The unpacked data does not match the original value.")
- }
- }
-}
diff --git a/Tests/PackTests/BinaryPackTests_littleEndian.swift b/Tests/PackTests/BinaryPackTests_littleEndian.swift
deleted file mode 100644
index a478003..0000000
--- a/Tests/PackTests/BinaryPackTests_littleEndian.swift
+++ /dev/null
@@ -1,996 +0,0 @@
-//
-// BinaryPackTests_littleEndian.swift
-// PackTests
-//
-// Created by Matt Cox on 13/02/2022.
-// Copyright © 2024 Matt Cox. All rights reserved.
-//
-
-import XCTest
-@testable import Pack
-import Foundation
-
-final class BinaryPackTests_littleEndian: XCTestCase {
- private let byteOrder: ByteOrder = .littleEndian
-
- private func packer(options: BinaryPack.Options = BinaryPack.defaultOptions) -> BinaryPack {
- BinaryPack(options: options, byteOrder: self.byteOrder)
- }
-
- private func packer(destination: OutputStream, options: BinaryPack.Options = BinaryPack.defaultOptions) -> BinaryPack {
- BinaryPack(writingTo: destination, options: options, byteOrder: self.byteOrder)
- }
-
- private func unpacker(source: Data, options: BinaryPack.Options = BinaryPack.defaultOptions) -> BinaryPack {
- BinaryPack(from: source, options: options, byteOrder: self.byteOrder)
- }
-
- private func unpacker(source: InputStream, options: BinaryPack.Options = BinaryPack.defaultOptions) -> BinaryPack {
- BinaryPack(readingFrom: source, options: options, byteOrder: self.byteOrder)
- }
-
- private func withOutputStream(size: Int = 1024, perform: (OutputStream) throws -> Void) rethrows -> Data {
- var buffer = ContiguousArray(unsafeUninitializedCapacity: size) { pointer, initialized in
- for i in 0.. Void) rethrows {
- let inputStream = InputStream(data: data)
- inputStream.open()
- defer {
- inputStream.close()
- }
-
- try perform(inputStream)
- }
-
- private func noisyData(_ byteCount: Int) -> Data {
- var data = Data()
- for _ in 0...size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Bool into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_Bool() throws {
- let value: Bool = true
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Bool = false
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Double into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_Double() throws {
- let value = Double.random(in: 0...Double.greatestFiniteMagnitude)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Double into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_Double() throws {
- let value = Double.random(in: 0...Double.greatestFiniteMagnitude)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Double = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Float into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_Float() throws {
- let value = Float.random(in: 0...Float.greatestFiniteMagnitude)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Float into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_Float() throws {
- let value = Float.random(in: 0...Float.greatestFiniteMagnitude)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Float = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_Int() throws {
- let value = Int.random(in: Int.min...Int.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_Int() throws {
- let value = Int.random(in: Int.min...Int.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Int = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int8 into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_Int8() throws {
- let value = Int8.random(in: Int8.min...Int8.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int8 into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_Int8() throws {
- let value = Int8.random(in: Int8.min...Int8.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Int8 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int16 into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_Int16() throws {
- let value = Int16.random(in: Int16.min...Int16.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int16 into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_Int16() throws {
- let value = Int16.random(in: Int16.min...Int16.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Int16 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int32 into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_Int32() throws {
- let value = Int32.random(in: Int32.min...Int32.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int32 into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_Int32() throws {
- let value = Int32.random(in: Int32.min...Int32.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Int32 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int64 into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_Int64() throws {
- let value = Int64.random(in: Int64.min...Int64.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an Int64 into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_Int64() throws {
- let value = Int64.random(in: Int64.min...Int64.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Int64 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_UInt() throws {
- let value = UInt.random(in: UInt.min...UInt.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_UInt() throws {
- let value = UInt.random(in: UInt.min...UInt.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: UInt = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt8 into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_UInt8() throws {
- let value = UInt8.random(in: UInt8.min...UInt8.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt8 into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_UInt8() throws {
- let value = UInt8.random(in: UInt8.min...UInt8.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: UInt8 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt16 into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_UInt16() throws {
- let value = UInt16.random(in: UInt16.min...UInt16.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt16 into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_UInt16() throws {
- let value = UInt16.random(in: UInt16.min...UInt16.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: UInt16 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt32 into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_UInt32() throws {
- let value = UInt32.random(in: UInt32.min...UInt32.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt32 into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_UInt32() throws {
- let value = UInt32.random(in: UInt32.min...UInt32.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: UInt32 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt64 into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_UInt64() throws {
- let value = UInt64.random(in: UInt64.min...UInt64.max)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == MemoryLayout.size, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a UInt64 into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_UInt64() throws {
- let value = UInt64.random(in: UInt64.min...UInt64.max)
-
- let data = try withOutputStream {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: UInt64 = .zero
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Data object into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_Data() throws {
- let value = self.noisyData(512)
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
- XCTAssertTrue(data.count == value.count, "The size of the packed data is larger than expected.")
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(type(of: value).self, size: value.count)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a Data object into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_Data() throws {
- let value = self.noisyData(512)
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- var result: Data = Data()
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- result = try unpacker.unpack(type(of: value).self, size: value.count)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with ascii encoding
-/// and the string prepended with size.
-///
- func test_packAndUnpack_toData_littleEndian_String_ascii_prependedWithSize() throws {
- let value = "I am a test string!"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .ascii)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .ascii)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with ascii encoding.
-///
- func test_packAndUnpack_toStream_littleEndian_String_ascii_prependedWithSize() throws {
- let value = "I am a test string!"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .ascii)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .ascii)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with ascii encoding
-/// and the string null terminated.
-///
- func test_packAndUnpack_toData_littleEndian_String_ascii_nullTerminated() throws {
- let value = "I am a test string!"
-
- let packer = self.packer(options: .stringsNullTerminated)
- try packer.pack(value, using: .ascii)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsNullTerminated)
- let result = try unpacker.unpack(type(of: value).self, using: .ascii)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with ascii encoding.
-///
- func test_packAndUnpack_toStream_littleEndian_String_ascii_nullTerminated() throws {
- let value = "I am a test string!"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsNullTerminated)
- try packer.pack(value, using: .ascii)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsNullTerminated)
- result = try unpacker.unpack(type(of: value).self, using: .ascii)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf8 encoding
-/// and the string prepended with size.
-///
- func test_packAndUnpack_toData_littleEndian_String_utf8_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf8)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf8)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf8 encoding.
-///
- func test_packAndUnpack_toStream_littleEndian_String_utf8_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf8)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf8)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf8 encoding
-/// and the string null terminated.
-///
- func test_packAndUnpack_toData_littleEndian_String_utf8_nullTerminated() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsNullTerminated)
- try packer.pack(value, using: .utf8)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsNullTerminated)
- let result = try unpacker.unpack(type(of: value).self, using: .utf8)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf8 encoding.
-///
- func test_packAndUnpack_toStream_littleEndian_String_utf8_nullTerminated() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsNullTerminated)
- try packer.pack(value, using: .utf8)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsNullTerminated)
- result = try unpacker.unpack(type(of: value).self, using: .utf8)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf16 encoding
-/// and the string prepended with size.
-///
- func test_packAndUnpack_toData_littleEndian_String_utf16_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf16)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf16)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf16 encoding.
-///
- func test_packAndUnpack_toStream_littleEndian_String_utf16_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf16)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf16)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf16BigEndian
-/// encoding and the string prepended with size.
-///
- func test_packAndUnpack_toData_littleEndian_String_utf16BigEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf16BigEndian)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf16BigEndian)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf16BigEndian encoding.
-///
- func test_packAndUnpack_toStream_littleEndian_String_utf16BigEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf16BigEndian)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf16BigEndian)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf16LittleEndian
-/// encoding and the string prepended with size.
-///
- func test_packAndUnpack_toData_littleEndian_String_utf16LittleEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf16LittleEndian)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf16LittleEndian)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf16LittleEndian encoding.
-///
- func test_packAndUnpack_toStream_littleEndian_String_utf16LittleEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf16LittleEndian)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf16LittleEndian)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf32 encoding
-/// and the string prepended with size.
-///
- func test_packAndUnpack_toData_littleEndian_String_utf32_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf32)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf32)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf32 encoding.
-///
- func test_packAndUnpack_toStream_littleEndian_String_utf32_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf32)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf32)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf32BigEndian
-/// encoding and the string prepended with size.
-///
- func test_packAndUnpack_toData_littleEndian_String_utf32BigEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf32BigEndian)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf32BigEndian)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf32BigEndian encoding.
-///
- func test_packAndUnpack_toStream_littleEndian_String_utf32BigEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf32BigEndian)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf32BigEndian)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a data object, with utf32LittleEndian
-/// encoding and the string prepended with size.
-///
- func test_packAndUnpack_toData_littleEndian_String_utf32LittleEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let packer = self.packer(options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf32LittleEndian)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data, options: .stringsPrependedWithSize)
- let result = try unpacker.unpack(type(of: value).self, using: .utf32LittleEndian)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing a String object into a stream, with utf32LittleEndian encoding.
-///
- func test_packAndUnpack_toStream_littleEndian_String_utf32LittleEndian_prependedWithSize() throws {
- let value = "I am a test string! 仮 🌙 Ok"
-
- let data = try withOutputStream(size: 512) {
- let packer = self.packer(destination: $0, options: .stringsPrependedWithSize)
- try packer.pack(value, using: .utf32LittleEndian)
- }
-
- var result: String = ""
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0, options: .stringsPrependedWithSize)
- result = try unpacker.unpack(type(of: value).self, using: .utf32LittleEndian)
- }
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing mixed types into a data object.
-///
- func test_packAndUnpack_toData_littleEndian_mixed() throws {
- let intValue = 1234
- let doubleValue = 678.987654321
- let stringValue = "I am a test string! 仮 🌙 Ok"
- let floatValue: Float = 0.7728
-
- let packer = self.packer()
-
- try packer.pack(intValue)
- try packer.pack(doubleValue)
- try packer.pack(stringValue, using: .utf8)
- try packer.pack(floatValue)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data)
-
- let intResult = try unpacker.unpack(type(of: intValue).self)
- let doubleResult = try unpacker.unpack(type(of: doubleValue).self)
- let stringResult = try unpacker.unpack(type(of: stringValue).self, using: .utf8)
- let floatResult = try unpacker.unpack(type(of: floatValue).self)
-
- XCTAssertTrue(intValue == intResult, "The unpacked data does not match the original value.")
- XCTAssertTrue(doubleValue == doubleResult, "The unpacked data does not match the original value.")
- XCTAssertTrue(stringValue == stringResult, "The unpacked data does not match the original value.")
- XCTAssertTrue(floatValue == floatResult, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing mixed types into a stream.
-///
- func test_packAndUnpack_toStream_littleEndian_mixed() throws {
- let intValue = 1234
- let doubleValue = 678.987654321
- let stringValue = "I am a test string! 仮 🌙 Ok"
- let floatValue: Float = 0.7728
-
- let data = try withOutputStream(size: 1024) {
- let packer = self.packer(destination: $0)
-
- try packer.pack(intValue)
- try packer.pack(doubleValue)
- try packer.pack(stringValue, using: .utf8)
- try packer.pack(floatValue)
- }
-
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
-
- let intResult = try unpacker.unpack(type(of: intValue).self)
- let doubleResult = try unpacker.unpack(type(of: doubleValue).self)
- let stringResult = try unpacker.unpack(type(of: stringValue).self, using: .utf8)
- let floatResult = try unpacker.unpack(type(of: floatValue).self)
-
- XCTAssertTrue(intValue == intResult, "The unpacked data does not match the original value.")
- XCTAssertTrue(doubleValue == doubleResult, "The unpacked data does not match the original value.")
- XCTAssertTrue(stringValue == stringResult, "The unpacked data does not match the original value.")
- XCTAssertTrue(floatValue == floatResult, "The unpacked data does not match the original value.")
- }
- }
-}
diff --git a/Tests/PackTests/BinaryPackTests_packed.swift b/Tests/PackTests/BinaryPackTests_packed.swift
deleted file mode 100644
index 50c4fe6..0000000
--- a/Tests/PackTests/BinaryPackTests_packed.swift
+++ /dev/null
@@ -1,170 +0,0 @@
-//
-// BinaryPackTests_packed.swift
-// PackTests
-//
-// Created by Matt Cox on 13/02/2022.
-// Copyright © 2024 Matt Cox. All rights reserved.
-//
-
-import XCTest
-@testable import Pack
-import Foundation
-
-final class BinaryPackTests_packed: XCTestCase {
- private func packer(options: BinaryPack.Options = BinaryPack.defaultOptions) -> BinaryPack {
- BinaryPack(options: options)
- }
-
- private func packer(destination: OutputStream, options: BinaryPack.Options = BinaryPack.defaultOptions) -> BinaryPack {
- BinaryPack(writingTo: destination, options: options)
- }
-
- private func unpacker(source: Data, options: BinaryPack.Options = BinaryPack.defaultOptions) -> BinaryPack {
- BinaryPack(from: source, options: options)
- }
-
- private func unpacker(source: InputStream, options: BinaryPack.Options = BinaryPack.defaultOptions) -> BinaryPack {
- BinaryPack(readingFrom: source, options: options)
- }
-
- private func withOutputStream(size: Int = 1024, perform: (OutputStream) throws -> Void) rethrows -> Data {
- var buffer = ContiguousArray(unsafeUninitializedCapacity: size) { pointer, initialized in
- for i in 0.. Void) rethrows {
- let inputStream = InputStream(data: data)
- inputStream.open()
- defer {
- inputStream.close()
- }
-
- try perform(inputStream)
- }
-
-/// A user info key that is used for testing the user info functionality.
-///
- static let userInfoKey = PackUserInfoKey(rawValue: "TestKey")
-
-/// Custom types that conforms to packable.
-///
- private struct MyNestedType: Equatable, Packed {
- let intValue: Int
- let stringValue: String
- let doubleValue: Double
-
- init() {
- self.intValue = 9876
- self.stringValue = "🌙🧀"
- self.doubleValue = 54321.12345
- }
-
- init(from unpacker: inout Unpacker) throws {
- XCTAssertEqual(unpacker.userInfo[userInfoKey] as? String, "First")
- unpacker.userInfo[userInfoKey] = "Second"
-
- self.intValue = try unpacker.unpack(Int.self)
- self.stringValue = try unpacker.unpack(String.self, using: .utf8)
- self.doubleValue = try unpacker.unpack(Double.self)
- }
-
- func pack(to packer: inout Packer) throws {
- XCTAssertEqual(packer.userInfo[userInfoKey] as? String, "First")
- packer.userInfo[userInfoKey] = "Second"
-
- try packer.pack(self.intValue)
- try packer.pack(self.stringValue, using: .utf8)
- try packer.pack(self.doubleValue)
- }
- }
-
- private struct MyType: Equatable, Packed {
- let intValue: Int
- let nestedValue: MyNestedType
- let doubleValue: Double
- let stringValue: String
- let floatValue: Float
-
- init() {
- self.intValue = 1234
- self.nestedValue = MyNestedType()
- self.doubleValue = 678.987654321
- self.stringValue = "I am a test string! 仮 🌙 Ok"
- self.floatValue = 0.7728
- }
-
- init(from unpacker: inout Unpacker) throws {
- self.intValue = try unpacker.unpack(Int.self)
-
- unpacker.userInfo[userInfoKey] = "First"
- self.nestedValue = try unpacker.unpack(MyNestedType.self)
- XCTAssertEqual(unpacker.userInfo[userInfoKey] as? String, "Second")
-
- self.doubleValue = try unpacker.unpack(Double.self)
- self.stringValue = try unpacker.unpack(String.self, using: .utf8)
- self.floatValue = try unpacker.unpack(Float.self)
- }
-
- func pack(to packer: inout Packer) throws {
- try packer.pack(self.intValue)
-
- packer.userInfo[userInfoKey] = "First"
- try packer.pack(self.nestedValue)
- XCTAssertEqual(packer.userInfo[userInfoKey] as? String, "Second")
-
- try packer.pack(self.doubleValue)
- try packer.pack(self.stringValue, using: .utf8)
- try packer.pack(self.floatValue)
- }
- }
-
-/// Tests packing an object that conforms to packable into a data object.
-///
- func test_packAndUnpack_toData_packed() throws {
- let value = MyType()
-
- let packer = self.packer()
- try packer.pack(value)
-
- let data = try packer.data
-
- let unpacker = self.unpacker(source: data)
- let result = try unpacker.unpack(MyType.self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
-
-/// Tests packing an object that conforms to packable into a stream.
-///
- func test_packAndUnpack_toStream_packed() throws {
- let value = MyType()
-
- let data = try withOutputStream(size: 1024) {
- let packer = self.packer(destination: $0)
- try packer.pack(value)
- }
-
- try withInputStream(wrapping: data) {
- let unpacker = self.unpacker(source: $0)
- let result = try unpacker.unpack(type(of: value).self)
-
- XCTAssertTrue(value == result, "The unpacked data does not match the original value.")
- }
- }
-}
diff --git a/Tests/PackTests/CollectionTests.swift b/Tests/PackTests/CollectionTests.swift
new file mode 100644
index 0000000..be083f9
--- /dev/null
+++ b/Tests/PackTests/CollectionTests.swift
@@ -0,0 +1,127 @@
+//
+// CollectionTests.swift
+// PackTests
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+import Testing
+
+@testable import Pack
+
+@Suite("Collections")
+struct CollectionTests {
+// The bulk paths take a shortcut when no reordering is needed, so both byte
+// orders have to be checked - one exercises the fast path on a little endian
+// host, the other the per element path.
+//
+ @Test("Integer collections round trip", arguments: [PackConfiguration.littleEndian, .bigEndian])
+ func integerCollection(configuration: PackConfiguration) throws {
+ let values: [UInt32] = [0x00000000, 0x11223344, 0xaabbccdd, 0xffffffff]
+
+ let data = try packedToData(configuration) { packer in
+ try packer.pack(contentsOf: values)
+ }
+
+ #expect(data.count == values.count * 4)
+
+ var unpacker = dataUnpacker(data, configuration)
+
+ #expect(try unpacker.unpack(UInt32.self, count: values.count) == values)
+ }
+
+ @Test("Bulk and per element packing agree", arguments: [PackConfiguration.littleEndian, .bigEndian])
+ func bulkMatchesIndividual(configuration: PackConfiguration) throws {
+ let values: [Int16] = [-32768, -1, 0, 1, 32767]
+
+ let bulk = try packedToData(configuration) { packer in
+ try packer.pack(contentsOf: values)
+ }
+
+ let individual = try packedToData(configuration) { packer in
+ for value in values {
+ try packer.pack(value)
+ }
+ }
+
+ #expect(bulk == individual)
+ }
+
+ @Test("Floating point collections round trip", arguments: [PackConfiguration.littleEndian, .bigEndian])
+ func floatingPointCollection(configuration: PackConfiguration) throws {
+ let floats: [Float] = [.pi, -0.5, 0, .greatestFiniteMagnitude]
+ let doubles: [Double] = [.pi, -0.5, 0, .greatestFiniteMagnitude]
+
+ let data = try packedToData(configuration) { packer in
+ try packer.pack(contentsOf: floats)
+ try packer.pack(contentsOf: doubles)
+ }
+
+ var unpacker = dataUnpacker(data, configuration)
+
+ #expect(try unpacker.unpack(Float.self, count: floats.count) == floats)
+ #expect(try unpacker.unpack(Double.self, count: doubles.count) == doubles)
+ }
+
+ @Test("Count prefixed collections round trip")
+ func countPrefixed() throws {
+ let values: [UInt16] = [1, 2, 3, 4, 5]
+
+ let data = try packedToData(.bigEndian) { packer in
+ try packer.pack(contentsOf: values, countPrefixedBy: .uint32)
+ }
+
+ #expect(data.hex == "00000005" + "0001000200030004" + "0005")
+
+ var unpacker = dataUnpacker(data, .bigEndian)
+
+ #expect(try unpacker.unpack(UInt16.self, countPrefixedBy: .uint32) == values)
+ }
+
+ @Test("An empty collection round trips")
+ func emptyCollection() throws {
+ let values: [UInt64] = []
+
+ let data = try packedToData(.bigEndian) { packer in
+ try packer.pack(contentsOf: values, countPrefixedBy: .uint16)
+ try packer.pack(UInt8(0xff))
+ }
+
+ #expect(data.hex == "0000" + "ff")
+
+ var unpacker = dataUnpacker(data, .bigEndian)
+ let unpacked: [UInt64] = try unpacker.unpack(UInt64.self, countPrefixedBy: .uint16)
+
+ #expect(unpacked == [])
+ #expect(try unpacker.unpack(UInt8.self) == 0xff)
+ }
+
+ @Test("A count too large for its prefix width is reported")
+ func countTooLarge() {
+ expectPackError(.valueOutOfRange) {
+ _ = try packedToData(.bigEndian) { packer in
+ try packer.pack(contentsOf: [UInt8](repeating: 0, count: 300), countPrefixedBy: .uint8)
+ }
+ }
+ }
+
+ @Test("Raw bytes are packed unchanged, whatever the byte order")
+ func rawData() throws {
+ let bytes = Data([0x01, 0x02, 0x03, 0x04])
+
+ for configuration in [PackConfiguration.littleEndian, .bigEndian] {
+ let data = try packedToData(configuration) { packer in
+ try packer.pack(bytes)
+ }
+
+ #expect(data == bytes)
+
+ var unpacker = dataUnpacker(data, configuration)
+ let unpacked = try unpacker.unpack(Data.self, byteCount: 4)
+
+ #expect(unpacked == bytes)
+ }
+ }
+}
diff --git a/Tests/PackTests/ErasureTests.swift b/Tests/PackTests/ErasureTests.swift
new file mode 100644
index 0000000..39d7bbe
--- /dev/null
+++ b/Tests/PackTests/ErasureTests.swift
@@ -0,0 +1,154 @@
+//
+// ErasureTests.swift
+// PackTests
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+import Testing
+
+@testable import Pack
+
+private func writeChunk(to packer: inout P) throws {
+ try packer.pack("CHNK", using: .ascii, framing: .fixed(byteCount: 4))
+ try packer.withLengthPrefix(.uint64) { packer in
+ try packer.pack(Double.pi)
+ try packer.pack(contentsOf: [UInt32(1), 2, 3], countPrefixedBy: .uint32)
+ }
+}
+
+@Suite("Erasure")
+struct ErasureTests {
+// The point of AnyPacker is that erasing changes nothing about the output.
+// If these diverge, a client forced to erase would be writing a different
+// file to one that is statically typed.
+//
+ @Test("Erased and concrete packers produce identical bytes")
+ func erasureEquivalence() throws {
+ let concrete = try packedToData(.littleEndian, writeChunk)
+
+ let backing = BinaryPacker(configuration: .littleEndian)
+ var erased = AnyPacker(backing)
+ try writeChunk(to: &erased)
+
+ #expect(backing.data == concrete)
+ }
+
+ @Test("Erasure preserves framing through a stream, which buffers")
+ func erasureThroughStream() throws {
+ let stream = OutputStream.toMemory()
+ stream.open()
+
+ let backing = BinaryPacker(writingTo: stream, configuration: .bigEndian)
+ var erased = AnyPacker(backing)
+
+ try erased.withLengthPrefix(.uint32) { packer in
+ try packer.pack(UInt16(7))
+ try packer.withLengthPrefix(.uint16) { packer in
+ try packer.pack(UInt8(8))
+ }
+ }
+
+ let data = stream.property(forKey: .dataWrittenToMemoryStreamKey) as? Data ?? Data()
+ stream.close()
+
+ // The outer body is 0007, the inner span's two byte prefix, and 08 -
+ // five bytes.
+ //
+ #expect(data.hex == "00000005" + "0007" + "0001" + "08")
+ }
+
+ @Test("An erased unpacker reads what an erased packer wrote")
+ func erasedRoundTrip() throws {
+ let backing = BinaryPacker(configuration: .littleEndian)
+ var packer = AnyPacker(backing)
+
+ let color = Color(name: "Erased", red: 0.1, green: 0.2, blue: 0.3)
+ try packer.pack(color)
+
+ var unpacker = AnyUnpacker(BinaryUnpacker(from: backing.data, configuration: .littleEndian))
+ let unpacked = try unpacker.unpack(Color.self)
+
+ #expect(unpacked == color)
+ }
+
+ @Test("A placeholder from an erased packer is still rejected elsewhere")
+ func erasedPlaceholderIdentity() throws {
+ var first = AnyPacker(BinaryPacker(configuration: .bigEndian))
+ var second = AnyPacker(BinaryPacker(configuration: .bigEndian))
+
+ let placeholder = try first.reserve(UInt32.self)
+
+ expectPackError(.invalidPlaceholder) {
+ try second.fill(placeholder, with: UInt32(1))
+ }
+ }
+
+ @Test("Wrapping an erased packer does not add a second layer")
+ func doubleErasure() throws {
+ let backing = BinaryPacker(configuration: .bigEndian)
+ let once = AnyPacker(backing)
+ var twice = AnyPacker(once)
+
+ try twice.pack(UInt16(0x1234))
+
+ #expect(backing.data.hex == "1234")
+ }
+
+ @Test("Erasure forwards position and capability")
+ func forwardsMetadata() throws {
+ let backing = BinaryPacker(configuration: .bigEndian)
+ var erased = AnyPacker(backing)
+
+ #expect(erased.patchesInPlace == true)
+ #expect(erased.configuration.byteOrder == .bigEndian)
+ #expect(erased.packedCount == 0)
+
+ try erased.pack(UInt32(1))
+
+ #expect(erased.packedCount == 4)
+ #expect(backing.packedCount == 4)
+ }
+
+ @Test("Erasure forwards user info")
+ func forwardsUserInfo() throws {
+ let key = PackUserInfoKey(rawValue: "test")
+ let backing = BinaryPacker(configuration: .bigEndian)
+ let erased = AnyPacker(backing)
+
+ erased.userInfo[key] = 42
+
+ #expect(backing.userInfo[key] as? Int == 42)
+ #expect(erased.userInfo[key] as? Int == 42)
+ }
+
+// The shape a client is forced into when the packer type cannot be known at
+// the point a closure is captured. This is what AnyPacker exists for.
+//
+ @Test("A closure captured before any packer existed can still pack")
+ func closureCapturedAheadOfTime() throws {
+ typealias Packing = (Any, inout AnyPacker) throws -> Void
+
+ var registry: [String: Packing] = [:]
+ registry["color"] = { value, packer in
+ guard let color = value as? Color else {
+ return
+ }
+
+ try packer.pack(color)
+ }
+
+ let color = Color(name: "Late", red: 1, green: 0, blue: 0)
+
+ let backing = BinaryPacker(configuration: .littleEndian)
+ var packer = AnyPacker(backing)
+ try registry["color"]?(color, &packer)
+
+ var unpacker = BinaryUnpacker(from: backing.data, configuration: .littleEndian)
+ let unpacked = try unpacker.unpack(Color.self)
+
+ #expect(unpacked == color)
+ }
+}
diff --git a/Tests/PackTests/FramingTests.swift b/Tests/PackTests/FramingTests.swift
new file mode 100644
index 0000000..71652ef
--- /dev/null
+++ b/Tests/PackTests/FramingTests.swift
@@ -0,0 +1,311 @@
+//
+// FramingTests.swift
+// PackTests
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+import Testing
+
+@testable import Pack
+
+// Written as generic functions rather than closures, so the same body can be
+// handed to packers with different destinations and specialise for each.
+//
+private func writeChunk(to packer: inout P) throws {
+ try packer.pack("MESH", using: .ascii, framing: .fixed(byteCount: 4))
+ try packer.withLengthPrefix(.uint64) { packer in
+ try packer.pack(Double.pi)
+ try packer.pack(contentsOf: [Float(1), Float(2), Float(3)], countPrefixedBy: .uint32)
+ try packer.pack("named", framing: .lengthPrefixed(.uint16))
+ }
+ try packer.pack(UInt32(0xdeadbeef))
+}
+
+private func writeNestedChunks(to packer: inout P) throws {
+ try packer.withLengthPrefix(.uint32) { packer in
+ try packer.pack(UInt8(1))
+
+ try packer.withLengthPrefix(.uint32) { packer in
+ try packer.pack(UInt8(2))
+
+ try packer.withLengthPrefix(.uint16) { packer in
+ try packer.pack(UInt8(3))
+ }
+ }
+
+ try packer.pack(UInt8(4))
+ }
+}
+
+private func writeEmptySpan(to packer: inout P) throws {
+ try packer.withLengthPrefix(.uint32) { _ in
+
+ }
+
+ try packer.pack(UInt8(0xff))
+}
+
+@Suite("Framing")
+struct FramingTests {
+// The invariant the buffering design exists to guarantee: a packer that can
+// patch its output and a packer that cannot must produce the same bytes for
+// the same calls. If this fails, the framing is not portable between a file
+// written to memory and one written to a stream.
+//
+ @Test("Memory and stream destinations produce identical bytes")
+ func byteIdentity() throws {
+ let fromData = try packedToData(.littleEndian, writeChunk)
+ let fromStream = try packedToStream(.littleEndian, writeChunk)
+
+ #expect(fromData == fromStream)
+ #expect(!fromData.isEmpty)
+ }
+
+ @Test("Nested spans produce identical bytes on both destinations")
+ func byteIdentityNested() throws {
+ let fromData = try packedToData(.bigEndian, writeNestedChunks)
+ let fromStream = try packedToStream(.bigEndian, writeNestedChunks)
+
+ #expect(fromData == fromStream)
+
+ // A span's length covers its body only, not its own prefix. The outer
+ // body is 01, the inner span's four byte prefix, the inner body, and
+ // 04 - ten bytes. The inner body is 02, the innermost two byte prefix
+ // and 03 - four bytes. The innermost body is 03 alone.
+ //
+ #expect(fromData.hex == "0000000a" + "01" + "00000004" + "02" + "0001" + "03" + "04")
+ }
+
+ @Test("An empty span records a length of zero")
+ func emptySpan() throws {
+ let fromData = try packedToData(.bigEndian, writeEmptySpan)
+ let fromStream = try packedToStream(.bigEndian, writeEmptySpan)
+
+ #expect(fromData.hex == "00000000" + "ff")
+ #expect(fromData == fromStream)
+ }
+
+ @Test("A length prefix too narrow for its span is reported")
+ func spanTooLarge() {
+ expectPackError(.valueOutOfRange) {
+ _ = try packedToData(.bigEndian) { packer in
+ try packer.withLengthPrefix(.uint8) { packer in
+ try packer.pack(contentsOf: [UInt8](repeating: 0, count: 300))
+ }
+ }
+ }
+ }
+
+ @Test("packedCount advances past reserved slots and buffered bodies")
+ func packedCount() throws {
+ _ = try packedToStream(.bigEndian) { packer in
+ #expect(packer.packedCount == 0)
+
+ try packer.withLengthPrefix(.uint32) { packer in
+ #expect(packer.packedCount == 4)
+ try packer.pack(UInt64(0))
+ #expect(packer.packedCount == 12)
+ }
+
+ #expect(packer.packedCount == 12)
+ }
+ }
+
+ @Test("A reader skips the remainder of a span it does not fully understand")
+ func forwardCompatibility() throws {
+ // A newer writer emits three fields within the span.
+ //
+ let data = try packedToData(.bigEndian) { packer in
+ try packer.withLengthPrefix(.uint64) { packer in
+ try packer.pack(UInt32(1))
+ try packer.pack(UInt32(2))
+ try packer.pack(UInt32(3))
+ }
+
+ try packer.pack(UInt32(0xdeadbeef))
+ }
+
+ // An older reader knows about only the first.
+ //
+ var unpacker = dataUnpacker(data, .bigEndian)
+ let length = try unpacker.unpack(UInt64.self)
+
+ let first = try unpacker.withLimit(byteCount: Int(length)) { unpacker in
+ try unpacker.unpack(UInt32.self)
+ }
+
+ #expect(first == 1)
+
+ // The remainder of the span was skipped, so what follows is still
+ // readable.
+ //
+ #expect(try unpacker.unpack(UInt32.self) == 0xdeadbeef)
+ }
+
+ @Test("Reading past the end of a span is reported as it happens")
+ func spanOverrun() throws {
+ let data = try packedToData(.bigEndian) { packer in
+ try packer.withLengthPrefix(.uint32) { packer in
+ try packer.pack(UInt32(1))
+ }
+
+ try packer.pack(UInt32(0xdeadbeef))
+ }
+
+ var unpacker = dataUnpacker(data, .bigEndian)
+ let length = try unpacker.unpack(UInt32.self)
+
+ expectPackError(.overrun) {
+ try unpacker.withLimit(byteCount: Int(length)) { unpacker in
+ _ = try unpacker.unpack(UInt32.self)
+
+ // The span holds four bytes. Reading a second value would run
+ // into the value that follows the span.
+ //
+ _ = try unpacker.unpack(UInt32.self)
+ }
+ }
+ }
+
+ @Test("A span longer than the remaining input is rejected when it opens")
+ func spanBeyondEndOfInput() throws {
+ let data = try packedToData(.bigEndian) { packer in
+ try packer.pack(UInt32(4))
+ }
+
+ var unpacker = dataUnpacker(data, .bigEndian)
+ _ = try unpacker.unpack(UInt32.self)
+
+ expectPackError(.endOfInput) {
+ try unpacker.withLimit(byteCount: 1024) { _ in
+
+ }
+ }
+ }
+
+ @Test("A span cannot extend beyond the span enclosing it")
+ func nestedSpanBeyondParent() throws {
+ var unpacker = dataUnpacker(Data(repeating: 0, count: 64), .bigEndian)
+
+ expectPackError(.overrun) {
+ try unpacker.withLimit(byteCount: 8) { unpacker in
+ try unpacker.withLimit(byteCount: 16) { _ in
+
+ }
+ }
+ }
+ }
+
+ @Test("Nested spans round trip through a stream source")
+ func nestedSpansFromStream() throws {
+ let data = try packedToData(.bigEndian) { packer in
+ try packer.withLengthPrefix(.uint32) { packer in
+ try packer.pack(UInt16(7))
+
+ try packer.withLengthPrefix(.uint32) { packer in
+ try packer.pack(UInt16(8))
+ try packer.pack(UInt16(9))
+ }
+ }
+ }
+
+ var unpacker = streamUnpacker(data, .bigEndian)
+ let outer = try unpacker.unpack(UInt32.self)
+
+ try unpacker.withLimit(byteCount: Int(outer)) { unpacker in
+ let marker = try unpacker.unpack(UInt16.self)
+ #expect(marker == 7)
+
+ let inner = try unpacker.unpack(UInt32.self)
+
+ // Only the first of the inner values is read; the limit skips the
+ // rest, which on a stream means reading and discarding.
+ //
+ try unpacker.withLimit(byteCount: Int(inner)) { unpacker in
+ let first = try unpacker.unpack(UInt16.self)
+ #expect(first == 8)
+ }
+ }
+
+ #expect(unpacker.unpackedCount == data.count)
+ }
+
+ @Test("unpackedCount and remainingCount track position")
+ func position() throws {
+ let data = try packedToData(.bigEndian) { packer in
+ try packer.pack(UInt32(1))
+ try packer.pack(UInt32(2))
+ try packer.pack(UInt32(3))
+ }
+
+ var unpacker = dataUnpacker(data, .bigEndian)
+
+ #expect(unpacker.unpackedCount == 0)
+ #expect(unpacker.remainingCount == 12)
+
+ _ = try unpacker.unpack(UInt32.self)
+
+ #expect(unpacker.unpackedCount == 4)
+ #expect(unpacker.remainingCount == 8)
+
+ // Within a span, the remainder reported is the span's, not the
+ // source's.
+ //
+ try unpacker.withLimit(byteCount: 4) { unpacker in
+ #expect(unpacker.remainingCount == 4)
+ _ = try unpacker.unpack(UInt16.self)
+ #expect(unpacker.remainingCount == 2)
+ }
+
+ #expect(unpacker.unpackedCount == 8)
+ #expect(unpacker.remainingCount == 4)
+ }
+
+ @Test("A stream source reports no remaining count outside a span")
+ func streamRemainingCount() throws {
+ let data = try packedToData(.bigEndian) { packer in
+ try packer.pack(UInt32(1))
+ try packer.pack(UInt32(2))
+ }
+
+ var unpacker = streamUnpacker(data, .bigEndian)
+
+ #expect(unpacker.remainingCount == nil)
+
+ try unpacker.withLimit(byteCount: 4) { unpacker in
+ #expect(unpacker.remainingCount == 4)
+ }
+
+ #expect(unpacker.remainingCount == nil)
+ }
+
+ @Test("A failure inside a span leaves the position where it failed")
+ func failureInsideSpanDoesNotSkip() throws {
+ struct Marker: Error {
+
+ }
+
+ let data = try packedToData(.bigEndian) { packer in
+ try packer.pack(UInt32(1))
+ try packer.pack(UInt32(2))
+ }
+
+ var unpacker = dataUnpacker(data, .bigEndian)
+
+ #expect(throws: Marker.self) {
+ try unpacker.withLimit(byteCount: 8) { unpacker in
+ _ = try unpacker.unpack(UInt32.self)
+ throw Marker()
+ }
+ }
+
+ // The span was not skipped past, so the second value is still there to
+ // be read.
+ //
+ #expect(unpacker.unpackedCount == 4)
+ #expect(try unpacker.unpack(UInt32.self) == 2)
+ }
+}
diff --git a/Tests/PackTests/MeasuringPackerTests.swift b/Tests/PackTests/MeasuringPackerTests.swift
new file mode 100644
index 0000000..97707de
--- /dev/null
+++ b/Tests/PackTests/MeasuringPackerTests.swift
@@ -0,0 +1,79 @@
+//
+// MeasuringPackerTests.swift
+// PackTests
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+import Testing
+
+@testable import Pack
+
+private func writeDocument(to packer: inout P) throws {
+ try packer.pack("MESH", using: .ascii, framing: .fixed(byteCount: 4))
+ try packer.withLengthPrefix(.uint64) { packer in
+ try packer.pack(Double.pi)
+ try packer.pack(contentsOf: [Float(1), 2, 3], countPrefixedBy: .uint32)
+ try packer.pack("a name", framing: .lengthPrefixed(.uint16))
+ }
+ try packer.pack(Color(name: "Measured", red: 1, green: 0, blue: 0))
+}
+
+@Suite("Measuring")
+struct MeasuringPackerTests {
+ @Test("A measured size matches the bytes actually packed")
+ func measuresAccurately() throws {
+ var measure = MeasuringPacker(configuration: .littleEndian)
+ try writeDocument(to: &measure)
+
+ let data = try packedToData(.littleEndian, writeDocument)
+
+ #expect(measure.packedCount == data.count)
+ }
+
+ @Test("A measuring packer produces no bytes but still validates")
+ func validatesWhileMeasuring() throws {
+ var measure = MeasuringPacker(configuration: .bigEndian)
+
+ expectPackError(.valueOutOfRange) {
+ try measure.pack("TOOLONG", using: .ascii, framing: .fixed(byteCount: 4))
+ }
+ }
+
+ @Test("Placeholder misuse is caught while measuring")
+ func placeholderMisuse() throws {
+ var measure = MeasuringPacker(configuration: .bigEndian)
+ var other = MeasuringPacker(configuration: .bigEndian)
+
+ let placeholder = try measure.reserve(UInt32.self)
+ try measure.fill(placeholder, with: UInt32(0))
+
+ expectPackError(.placeholderAlreadyFilled) {
+ try measure.fill(placeholder, with: UInt32(0))
+ }
+
+ let foreign = try other.reserve(UInt32.self)
+
+ expectPackError(.invalidPlaceholder) {
+ try measure.fill(foreign, with: UInt32(0))
+ }
+ }
+
+ @Test("Measuring can size a destination before packing into it")
+ func reserveCapacity() throws {
+ var measure = MeasuringPacker(configuration: .littleEndian)
+ try measure.pack(contentsOf: [Double](repeating: .pi, count: 1000))
+
+ #expect(measure.packedCount == 8000)
+
+ var destination = DataDestination()
+ destination.reserveCapacity(measure.packedCount)
+
+ var packer = BinaryPacker(destination: destination, configuration: .littleEndian)
+ try packer.pack(contentsOf: [Double](repeating: .pi, count: 1000))
+
+ #expect(packer.destination.data.count == 8000)
+ }
+}
diff --git a/Tests/PackTests/PlaceholderTests.swift b/Tests/PackTests/PlaceholderTests.swift
new file mode 100644
index 0000000..f1b4f8c
--- /dev/null
+++ b/Tests/PackTests/PlaceholderTests.swift
@@ -0,0 +1,294 @@
+//
+// PlaceholderTests.swift
+// PackTests
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+import Testing
+
+@testable import Pack
+
+@Suite("Placeholders")
+struct PlaceholderTests {
+ @Test("A reserved slot is filled in place")
+ func reserveAndFill() throws {
+ var packer = BinaryPacker(configuration: .bigEndian)
+
+ let placeholder = try packer.reserve(UInt32.self)
+ try packer.pack(UInt16(0xaaaa))
+ try packer.fill(placeholder, with: UInt32(0xdeadbeef))
+
+ #expect(packer.data.hex == "deadbeef" + "aaaa")
+ #expect(packer.hasOpenPlaceholders == false)
+ }
+
+ @Test("An unfilled slot is reported")
+ func openPlaceholder() throws {
+ var packer = BinaryPacker(configuration: .bigEndian)
+
+ _ = try packer.reserve(UInt32.self)
+ try packer.pack(UInt16(0xaaaa))
+
+ #expect(packer.hasOpenPlaceholders == true)
+ #expect(packer.data.hex == "00000000" + "aaaa")
+ }
+
+ @Test("A placeholder from another packer is rejected")
+ func wrongOwner() throws {
+ var first = BinaryPacker(configuration: .bigEndian)
+ var second = BinaryPacker(configuration: .bigEndian)
+
+ let placeholder = try first.reserve(UInt32.self)
+
+ expectPackError(.invalidPlaceholder) {
+ try second.fill(placeholder, with: UInt32(1))
+ }
+ }
+
+ @Test("A placeholder cannot be filled twice")
+ func doubleFill() throws {
+ var packer = BinaryPacker(configuration: .bigEndian)
+
+ let placeholder = try packer.reserve(UInt32.self)
+ try packer.fill(placeholder, with: UInt32(1))
+
+ expectPackError(.placeholderAlreadyFilled) {
+ try packer.fill(placeholder, with: UInt32(2))
+ }
+ }
+
+ @Test("A value of the wrong width cannot fill a placeholder")
+ func mismatchedWidth() throws {
+ var packer = BinaryPacker(configuration: .bigEndian)
+
+ let placeholder = try packer.reserve(UInt32.self)
+
+ expectPackError(.valueOutOfRange) {
+ try packer.fill(placeholder, with: UInt16(1))
+ }
+ }
+
+// Filling out of order has to behave the same everywhere, because a caller
+// cannot see which destination they will be handed. A buffering destination
+// holds the span until everything ahead of it is resolved; a patchable one
+// revises in place. Only peak memory differs.
+//
+ @Test("Fills are accepted in any order on every destination")
+ func outOfOrderFill() throws {
+ var inMemory = BinaryPacker(configuration: .bigEndian)
+ let firstSlot = try inMemory.reserve(UInt32.self)
+ try inMemory.pack(UInt8(0xaa))
+ let secondSlot = try inMemory.reserve(UInt32.self)
+ try inMemory.pack(UInt8(0xbb))
+
+ // Outermost filled first, which a buffering destination cannot write
+ // out until it arrives.
+ //
+ try inMemory.fill(firstSlot, with: UInt32(1))
+ try inMemory.fill(secondSlot, with: UInt32(2))
+ try inMemory.finish()
+
+ let onStream = try packedToStream(.bigEndian) { packer in
+ let firstSlot = try packer.reserve(UInt32.self)
+ try packer.pack(UInt8(0xaa))
+ let secondSlot = try packer.reserve(UInt32.self)
+ try packer.pack(UInt8(0xbb))
+
+ try packer.fill(firstSlot, with: UInt32(1))
+ try packer.fill(secondSlot, with: UInt32(2))
+ try packer.finish()
+ }
+
+ #expect(inMemory.data.hex == "00000001" + "aa" + "00000002" + "bb")
+ #expect(inMemory.data == onStream)
+ }
+
+ @Test("Reversed fills also agree across destinations")
+ func reversedFill() throws {
+ var inMemory = BinaryPacker(configuration: .bigEndian)
+ let firstSlot = try inMemory.reserve(UInt32.self)
+ try inMemory.pack(UInt8(0xaa))
+ let secondSlot = try inMemory.reserve(UInt32.self)
+ try inMemory.pack(UInt8(0xbb))
+
+ try inMemory.fill(secondSlot, with: UInt32(2))
+ try inMemory.fill(firstSlot, with: UInt32(1))
+ try inMemory.finish()
+
+ let onStream = try packedToStream(.bigEndian) { packer in
+ let firstSlot = try packer.reserve(UInt32.self)
+ try packer.pack(UInt8(0xaa))
+ let secondSlot = try packer.reserve(UInt32.self)
+ try packer.pack(UInt8(0xbb))
+
+ try packer.fill(secondSlot, with: UInt32(2))
+ try packer.fill(firstSlot, with: UInt32(1))
+ try packer.finish()
+ }
+
+ #expect(inMemory.data.hex == "00000001" + "aa" + "00000002" + "bb")
+ #expect(inMemory.data == onStream)
+ }
+
+ @Test("A measuring packer accepts fills in any order too")
+ func outOfOrderFillWhileMeasuring() throws {
+ var measure = MeasuringPacker(configuration: .bigEndian)
+
+ let firstSlot = try measure.reserve(UInt32.self)
+ try measure.pack(UInt8(0xaa))
+ let secondSlot = try measure.reserve(UInt32.self)
+ try measure.pack(UInt8(0xbb))
+
+ try measure.fill(firstSlot, with: UInt32(1))
+ try measure.fill(secondSlot, with: UInt32(2))
+ try measure.finish()
+
+ #expect(measure.packedCount == 10)
+ }
+
+// A packer counts from zero, so its placeholder offsets are relative to
+// itself. A destination holding bytes already has to account for that, or the
+// patch lands over the preamble and the reserved slot is left as it was.
+//
+ @Test("Packing after existing bytes preserves them and patches the right slot")
+ func seededDestination() throws {
+ let preamble = Data([0xaa, 0xaa, 0xaa, 0xaa])
+ var packer = BinaryPacker(
+ destination: DataDestination(preamble),
+ configuration: .littleEndian
+ )
+
+ try packer.withLengthPrefix(.uint32) { packer in
+ try packer.pack(UInt32(0xbbbbbbbb))
+ }
+
+ try packer.finish()
+
+ #expect(packer.data.hex == "aaaaaaaa" + "04000000" + "bbbbbbbb")
+ #expect(packer.data.prefix(4) == preamble)
+ }
+
+ @Test("A seeded destination counts only what was packed into it")
+ func seededDestinationCount() throws {
+ var packer = BinaryPacker(
+ destination: DataDestination(Data([0xaa, 0xaa, 0xaa, 0xaa])),
+ configuration: .littleEndian
+ )
+
+ try packer.pack(UInt32(0))
+
+ #expect(packer.packedCount == 4)
+ #expect(packer.destination.count == 4)
+ #expect(packer.data.count == 8)
+ }
+
+ @Test("A seeded destination cannot be patched over its preamble")
+ func seededDestinationBounds() throws {
+ var destination = DataDestination(Data([0xaa, 0xaa]))
+ var bytes: [UInt8] = [0xff, 0xff]
+
+ expectPackError(.destinationFailure) {
+ try bytes.withUnsafeMutableBytes { buffer in
+ try destination.patch(at: 0, with: UnsafeRawBufferPointer(buffer))
+ }
+ }
+
+ #expect(destination.data.hex == "aaaa")
+ }
+
+// A span whose body throws is never filled. On a buffering destination its
+// bytes are held and everything after it is swallowed, so the output looks
+// finished but is not. finish() is what makes that visible.
+//
+ @Test("An abandoned span is reported rather than silently dropped")
+ func abandonedSpanIsReported() throws {
+ struct Marker: Error {
+
+ }
+
+ let stream = OutputStream.toMemory()
+ stream.open()
+
+ defer {
+ stream.close()
+ }
+
+ var packer = BinaryPacker(writingTo: stream, configuration: .bigEndian)
+ try packer.pack(UInt32(0xdeadbeef))
+
+ #expect(throws: Marker.self) {
+ try packer.withLengthPrefix(.uint32) { packer in
+ try packer.pack(UInt16(1))
+ throw Marker()
+ }
+ }
+
+ #expect(packer.hasOpenPlaceholders == true)
+
+ expectPackError(.incompleteOutput) {
+ try packer.finish()
+ }
+ }
+
+ @Test("An abandoned span is reported on a patchable destination too")
+ func abandonedSpanInMemory() throws {
+ struct Marker: Error {
+
+ }
+
+ var packer = BinaryPacker(configuration: .bigEndian)
+
+ #expect(throws: Marker.self) {
+ try packer.withLengthPrefix(.uint32) { packer in
+ try packer.pack(UInt16(1))
+ throw Marker()
+ }
+ }
+
+ #expect(packer.hasOpenPlaceholders == true)
+
+ expectPackError(.incompleteOutput) {
+ try packer.finish()
+ }
+ }
+
+ @Test("An incomplete state is visible through erasure")
+ func abandonedSpanThroughErasure() throws {
+ var erased = AnyPacker(BinaryPacker(configuration: .bigEndian))
+
+ _ = try erased.reserve(UInt32.self)
+
+ #expect(erased.hasOpenPlaceholders == true)
+
+ expectPackError(.incompleteOutput) {
+ try erased.finish()
+ }
+ }
+
+ @Test("finish succeeds once every slot is filled")
+ func finishSucceeds() throws {
+ var packer = BinaryPacker(configuration: .bigEndian)
+
+ try packer.withLengthPrefix(.uint32) { packer in
+ try packer.pack(UInt16(1))
+ }
+
+ #expect(packer.hasOpenPlaceholders == false)
+ try packer.finish()
+ }
+
+ @Test("A packer reports whether it patches in place")
+ func patchesInPlace() throws {
+ var packer = BinaryPacker(configuration: .bigEndian)
+ #expect(packer.patchesInPlace == true)
+ try packer.pack(UInt8(0))
+
+ _ = try packedToStream(.bigEndian) { packer in
+ #expect(packer.patchesInPlace == false)
+ try packer.pack(UInt8(0))
+ }
+ }
+}
diff --git a/Tests/PackTests/ScalarTests.swift b/Tests/PackTests/ScalarTests.swift
new file mode 100644
index 0000000..e2b9b5d
--- /dev/null
+++ b/Tests/PackTests/ScalarTests.swift
@@ -0,0 +1,149 @@
+//
+// ScalarTests.swift
+// PackTests
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+import Testing
+
+@testable import Pack
+
+@Suite("Scalars")
+struct ScalarTests {
+// Golden expectations. These pin the encoding to specific bytes, so that a
+// change to how values are encoded fails here rather than round tripping
+// incorrectly against itself.
+//
+ @Test("Integers pack to known bytes, little endian")
+ func integerGoldenLittleEndian() throws {
+ let data = try packedToData(.littleEndian) { packer in
+ try packer.pack(UInt8(0x12))
+ try packer.pack(UInt16(0x1234))
+ try packer.pack(UInt32(0x12345678))
+ try packer.pack(UInt64(0x123456789abcdef0))
+ try packer.pack(Int16(-2))
+ try packer.pack(Int32(-2))
+ }
+
+ #expect(data.hex == "12" + "3412" + "78563412" + "f0debc9a78563412" + "feff" + "feffffff")
+ }
+
+ @Test("Integers pack to known bytes, big endian")
+ func integerGoldenBigEndian() throws {
+ let data = try packedToData(.bigEndian) { packer in
+ try packer.pack(UInt8(0x12))
+ try packer.pack(UInt16(0x1234))
+ try packer.pack(UInt32(0x12345678))
+ try packer.pack(UInt64(0x123456789abcdef0))
+ try packer.pack(Int16(-2))
+ try packer.pack(Int32(-2))
+ }
+
+ #expect(data.hex == "12" + "1234" + "12345678" + "123456789abcdef0" + "fffe" + "fffffffe")
+ }
+
+ @Test("Floating point values pack to their bit patterns")
+ func floatingPointGolden() throws {
+ let data = try packedToData(.bigEndian) { packer in
+ try packer.pack(Float(1.0))
+ try packer.pack(Double(1.0))
+ }
+
+ #expect(data.hex == "3f800000" + "3ff0000000000000")
+ }
+
+ @Test("Bool packs as a single byte")
+ func boolGolden() throws {
+ let data = try packedToData(.littleEndian) { packer in
+ try packer.pack(true)
+ try packer.pack(false)
+ }
+
+ #expect(data.hex == "0100")
+ }
+
+ @Test("Scalars round trip", arguments: [PackConfiguration.littleEndian, .bigEndian])
+ func scalarRoundTrip(configuration: PackConfiguration) throws {
+ let data = try packedToData(configuration) { packer in
+ try packer.pack(Int8.min)
+ try packer.pack(Int16.min)
+ try packer.pack(Int32.min)
+ try packer.pack(Int64.min)
+ try packer.pack(UInt8.max)
+ try packer.pack(UInt16.max)
+ try packer.pack(UInt32.max)
+ try packer.pack(UInt64.max)
+ try packer.pack(Float.pi)
+ try packer.pack(Double.pi)
+ try packer.pack(true)
+ try packer.pack(false)
+ }
+
+ var unpacker = dataUnpacker(data, configuration)
+
+ #expect(try unpacker.unpack(Int8.self) == Int8.min)
+ #expect(try unpacker.unpack(Int16.self) == Int16.min)
+ #expect(try unpacker.unpack(Int32.self) == Int32.min)
+ #expect(try unpacker.unpack(Int64.self) == Int64.min)
+ #expect(try unpacker.unpack(UInt8.self) == UInt8.max)
+ #expect(try unpacker.unpack(UInt16.self) == UInt16.max)
+ #expect(try unpacker.unpack(UInt32.self) == UInt32.max)
+ #expect(try unpacker.unpack(UInt64.self) == UInt64.max)
+ #expect(try unpacker.unpack(Float.self) == Float.pi)
+ #expect(try unpacker.unpack(Double.self) == Double.pi)
+ #expect(try unpacker.unpack(Bool.self) == true)
+ #expect(try unpacker.unpack(Bool.self) == false)
+ #expect(unpacker.remainingCount == 0)
+ }
+
+// Erasing the value being packed - as opposed to erasing the packer - still
+// works, because the argument is a normal parameter and Swift opens the
+// existential into the generic one. This is the distinction that made
+// AnyPacker necessary while `any Packable` remained usable.
+//
+ @Test("An existential Packable can be packed directly")
+ func existentialPackable() throws {
+ let values: [any Packable] = [Int32(-12345), UInt16(65535), true]
+
+ let data = try packedToData(.bigEndian) { packer in
+ for value in values {
+ try packer.pack(value)
+ }
+ }
+
+ var unpacker = dataUnpacker(data, .bigEndian)
+
+ #expect(try unpacker.unpack(Int32.self) == -12345)
+ #expect(try unpacker.unpack(UInt16.self) == 65535)
+ #expect(try unpacker.unpack(Bool.self) == true)
+ }
+
+ @Test("Unpacking past the end of the source is reported")
+ func endOfInput() throws {
+ let data = try packedToData(.littleEndian) { packer in
+ try packer.pack(UInt16(1))
+ }
+
+ var unpacker = dataUnpacker(data, .littleEndian)
+
+ expectPackError(.endOfInput) {
+ _ = try unpacker.unpack(UInt32.self)
+ }
+ }
+
+ @Test("A Packable value round trips")
+ func packableRoundTrip() throws {
+ let color = Color(name: "Rebecca Purple", red: 0.4, green: 0.2, blue: 0.6)
+
+ let data = try packedToData(.littleEndian) { packer in
+ try packer.pack(color)
+ }
+
+ var unpacker = dataUnpacker(data, .littleEndian)
+
+ #expect(try unpacker.unpack(Color.self) == color)
+ }
+}
diff --git a/Tests/PackTests/StringTests.swift b/Tests/PackTests/StringTests.swift
new file mode 100644
index 0000000..6f42500
--- /dev/null
+++ b/Tests/PackTests/StringTests.swift
@@ -0,0 +1,166 @@
+//
+// StringTests.swift
+// PackTests
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+import Testing
+
+@testable import Pack
+
+private let allFramings: [StringFraming] = [
+ .lengthPrefixed(.uint8),
+ .lengthPrefixed(.uint16),
+ .lengthPrefixed(.uint32),
+ .lengthPrefixed(.uint64),
+ .nullTerminated,
+ .fixed(byteCount: 32),
+]
+
+@Suite("Strings")
+struct StringTests {
+ @Test("Strings round trip under every framing", arguments: allFramings)
+ func roundTrip(framing: StringFraming) throws {
+ let data = try packedToData(.littleEndian) { packer in
+ try packer.pack("Hello, world!", using: .utf8, framing: framing)
+ try packer.pack(UInt32(0xdeadbeef))
+ }
+
+ var unpacker = dataUnpacker(data, .littleEndian)
+
+ #expect(try unpacker.unpack(String.self, using: .utf8, framing: framing) == "Hello, world!")
+ #expect(try unpacker.unpack(UInt32.self) == 0xdeadbeef)
+ }
+
+// Pack 1 always wrote a null terminator but only consumed one when the
+// decoded length was greater than zero, so an empty string desynchronised the
+// stream. The framings are mutually exclusive now, but the empty case is
+// still worth pinning for each of them.
+//
+ @Test("Empty strings round trip without desynchronising", arguments: allFramings)
+ func emptyString(framing: StringFraming) throws {
+ let data = try packedToData(.littleEndian) { packer in
+ try packer.pack("", using: .utf8, framing: framing)
+ try packer.pack(UInt32(0xdeadbeef))
+ }
+
+ var unpacker = dataUnpacker(data, .littleEndian)
+
+ #expect(try unpacker.unpack(String.self, using: .utf8, framing: framing) == "")
+ #expect(try unpacker.unpack(UInt32.self) == 0xdeadbeef)
+ }
+
+ @Test("A length prefix records the byte count, not the character count")
+ func lengthPrefixGolden() throws {
+ let data = try packedToData(.bigEndian) { packer in
+ try packer.pack("é", using: .utf8, framing: .lengthPrefixed(.uint32))
+ }
+
+ // Two bytes of utf8, preceded by a four byte count.
+ //
+ #expect(data.hex == "00000002" + "c3a9")
+ }
+
+ @Test("Fixed size strings are padded, and the padding is stripped")
+ func fixedSizePadding() throws {
+ let data = try packedToData(.bigEndian) { packer in
+ try packer.pack("FORM", using: .ascii, framing: .fixed(byteCount: 4))
+ try packer.pack("ID", using: .ascii, framing: .fixed(byteCount: 4, padding: 0x20))
+ }
+
+ #expect(data.hex == "464f524d" + "49442020")
+
+ var unpacker = dataUnpacker(data, .bigEndian)
+
+ #expect(try unpacker.unpack(String.self, using: .ascii, framing: .fixed(byteCount: 4)) == "FORM")
+ #expect(try unpacker.unpack(String.self, using: .ascii, framing: .fixed(byteCount: 4, padding: 0x20)) == "ID")
+ }
+
+ @Test("A string too long for a fixed size field is rejected, not truncated")
+ func fixedSizeOverflow() {
+ expectPackError(.valueOutOfRange) {
+ _ = try packedToData(.bigEndian) { packer in
+ try packer.pack("TOOLONG", using: .ascii, framing: .fixed(byteCount: 4))
+ }
+ }
+ }
+
+ @Test("A string too long for its length prefix is rejected")
+ func lengthPrefixOverflow() {
+ expectPackError(.valueOutOfRange) {
+ _ = try packedToData(.bigEndian) { packer in
+ try packer.pack(String(repeating: "a", count: 300), using: .ascii, framing: .lengthPrefixed(.uint8))
+ }
+ }
+ }
+
+ @Test("Null terminated framing rejects encodings that can contain zero bytes")
+ func nullTerminatedEncoding() {
+ expectPackError(.unsupportedEncoding) {
+ _ = try packedToData(.bigEndian) { packer in
+ try packer.pack("Hello", using: .utf16, framing: .nullTerminated)
+ }
+ }
+ }
+
+ @Test("A null terminated string containing a null character is rejected")
+ func nullTerminatedEmbeddedNull() {
+ expectPackError(.invalidString) {
+ _ = try packedToData(.bigEndian) { packer in
+ try packer.pack("a\u{0}b", using: .utf8, framing: .nullTerminated)
+ }
+ }
+ }
+
+ @Test("Strings framed to the end of the input round trip")
+ func untilEnd() throws {
+ let data = try packedToData(.littleEndian) { packer in
+ try packer.pack(UInt32(7))
+ try packer.pack("trailing", using: .utf8, framing: .untilEnd)
+ }
+
+ var unpacker = dataUnpacker(data, .littleEndian)
+
+ #expect(try unpacker.unpack(UInt32.self) == 7)
+ #expect(try unpacker.unpack(String.self, using: .utf8, framing: .untilEnd) == "trailing")
+
+ // The same, from a stream, where the remaining length is unknowable.
+ //
+ var fromStream = streamUnpacker(data, .littleEndian)
+
+ #expect(try fromStream.unpack(UInt32.self) == 7)
+ #expect(try fromStream.unpack(String.self, using: .utf8, framing: .untilEnd) == "trailing")
+ }
+
+ @Test("The configuration supplies the default encoding and framing")
+ func configurationDefaults() throws {
+ let configuration = PackConfiguration(
+ byteOrder: .bigEndian,
+ defaultStringEncoding: .utf16BigEndian,
+ defaultStringFraming: .lengthPrefixed(.uint16)
+ )
+
+ let data = try packedToData(configuration) { packer in
+ try packer.pack("hi")
+ }
+
+ #expect(data.hex == "0004" + "00680069")
+
+ var unpacker = dataUnpacker(data, configuration)
+
+ #expect(try unpacker.unpack(String.self) == "hi")
+ }
+
+ @Test("Bytes that are invalid in the requested encoding are reported")
+ func invalidEncoding() throws {
+ let data = Data([0x00, 0x00, 0x00, 0x02, 0xff, 0xfe])
+ var unpacker = dataUnpacker(data, .bigEndian)
+
+ expectPackError(.invalidString) {
+ _ = try unpacker.unpack(String.self, using: .utf8, framing: .lengthPrefixed(.uint32))
+ }
+ }
+}
diff --git a/Tests/PackTests/TestSupport.swift b/Tests/PackTests/TestSupport.swift
new file mode 100644
index 0000000..93e6f0c
--- /dev/null
+++ b/Tests/PackTests/TestSupport.swift
@@ -0,0 +1,131 @@
+//
+// TestSupport.swift
+// PackTests
+//
+// Created by Matt Cox on 03/08/2026.
+// Copyright © 2026 Matt Cox. All rights reserved.
+//
+
+import Foundation
+import Testing
+
+@testable import Pack
+
+/// Packs into memory and returns the resulting bytes.
+///
+func packedToData(
+ _ configuration: PackConfiguration,
+ _ body: (inout BinaryPacker) throws -> Void
+) throws -> Data {
+ var packer = BinaryPacker(configuration: configuration)
+ try body(&packer)
+ return packer.data
+}
+
+/// Packs into an in-memory `OutputStream` and returns the resulting bytes.
+///
+/// This exists so that the stream path - which cannot patch, and therefore
+/// buffers reserved spans - can be compared against the memory path.
+///
+func packedToStream(
+ _ configuration: PackConfiguration,
+ _ body: (inout BinaryPacker) throws -> Void
+) throws -> Data {
+ let stream = OutputStream.toMemory()
+ stream.open()
+
+ defer {
+ stream.close()
+ }
+
+ var packer = BinaryPacker(writingTo: stream, configuration: configuration)
+ try body(&packer)
+
+ return stream.property(forKey: .dataWrittenToMemoryStreamKey) as? Data ?? Data()
+}
+
+/// An unpacker reading bytes held in memory.
+///
+/// This returns the unpacker rather than taking a closure, because `#expect`
+/// cannot evaluate a throwing expression inside one.
+///
+func dataUnpacker(_ data: Data, _ configuration: PackConfiguration) -> BinaryUnpacker {
+ BinaryUnpacker(from: data, configuration: configuration)
+}
+
+/// An unpacker reading from an in-memory `InputStream`.
+///
+/// The stream is retained by the source, and is left open for the lifetime of
+/// the unpacker.
+///
+func streamUnpacker(_ data: Data, _ configuration: PackConfiguration) -> BinaryUnpacker {
+ let stream = InputStream(data: data)
+ stream.open()
+
+ return BinaryUnpacker(readingFrom: stream, configuration: configuration)
+}
+
+/// Asserts that a closure throws a ``PackError`` with the given code.
+///
+func expectPackError(
+ _ code: PackError.Code,
+ sourceLocation: SourceLocation = #_sourceLocation,
+ _ body: () throws -> Void
+) {
+ do {
+ try body()
+ Issue.record("Expected PackError.\(code), but nothing was thrown", sourceLocation: sourceLocation)
+ }
+ catch let error as PackError {
+ #expect(error.code == code, "Expected PackError.\(code), got \(error)", sourceLocation: sourceLocation)
+ }
+ catch {
+ Issue.record("Expected PackError.\(code), got \(error)", sourceLocation: sourceLocation)
+ }
+}
+
+extension Data {
+/// The bytes as a lowercase hex string, for readable expectations.
+///
+ var hex: String {
+ map {
+ String(format: "%02x", $0)
+ }
+ .joined()
+ }
+}
+
+/// A value with an explicit, hand written layout, used to exercise the
+/// ``Packable`` and ``Unpackable`` conformances.
+///
+struct Color: Packed, Equatable {
+ var name: String
+ var red: Double
+ var green: Double
+ var blue: Double
+ var alpha: Double
+
+ init(name: String, red: Double, green: Double, blue: Double, alpha: Double = 1.0) {
+ self.name = name
+ self.red = red
+ self.green = green
+ self.blue = blue
+ self.alpha = alpha
+ }
+
+ init(from unpacker: inout some Unpacker) throws {
+ self.name = try unpacker.unpack(String.self, using: .utf16, framing: .lengthPrefixed(.uint32))
+ self.red = try unpacker.unpack(Double.self)
+ self.green = try unpacker.unpack(Double.self)
+ self.blue = try unpacker.unpack(Double.self)
+ self.alpha = try unpacker.unpack(Double.self)
+ }
+
+ func pack(to packer: inout some Packer) throws {
+ try packer.pack(name, using: .utf16, framing: .lengthPrefixed(.uint32))
+ try packer.pack(red)
+ try packer.pack(green)
+ try packer.pack(blue)
+ try packer.pack(alpha)
+ }
+}