Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
# Category C (tools-version 6.3): macos-15 + 6.3, macos-26 + 6.3
# Linux: ubuntu + Swift 6.3
#
# This package is Category A, plus a Linux leg on Swift 6.3. Test names are raw
# This package is Category A, plus a Linux leg. Test names are raw
# identifiers (SE-0451), so a leg must be on Swift 6.2 or newer to run `swift test`;
# a leg on an older compiler is limited to `swift build -v`.
#
# When Swift 6.4 ships: add 6.4 legs alongside 6.2

name: CI

Expand All @@ -32,8 +30,12 @@ jobs:
include:
- os: macos-26
swift: "6.2"
- os: macos-26
swift: "6.4"
- os: ubuntu-latest
swift: "6.3"
- os: ubuntu-latest
swift: "6.4"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Change Log

## [Unreleased]

### Changed

- Adopt typed throws across the parsing surface: `DOF.init(data:)`, `DOF.init(url:)`, the `DOF.from(…)` factories, and `DOFByteParser` now declare `throws(DOFError)`, and the DOF file line reader's `AsyncIteratorProtocol.Failure` is `DOFError`. `AsyncBytesLineReader` propagates its source sequence's own `Failure` type.
- `DOF.from(filePath:)` streams the file in chunks instead of reading it into memory in its entirety. A file that cannot be opened now throws `DOFError.fileNotFound` rather than a Foundation file-read error.
- Match the header's "CURRENCY DATE = " marker against an `InlineArray<16, UInt8>`, removing a heap allocation from currency date parsing.

### Fixed

- `Cycle.previous`, `Cycle.next`, and the cycle datum date no longer force-unwrap optionals.

## [1.3.0] - 2026-09-14

### Changed
Expand Down
5 changes: 3 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ let upcomingFeatures: [SwiftSetting] = [
.enableUpcomingFeature("ImmutableWeakCaptures"),
.enableUpcomingFeature("MemberImportVisibility"),
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InternalImportsByDefault")
.enableUpcomingFeature("InternalImportsByDefault"),
.strictMemorySafety()
]

let package = Package(
name: "SwiftDOF",
defaultLocalization: "en",
platforms: [.macOS(.v15), .iOS(.v18), .watchOS(.v11), .tvOS(.v18), .visionOS(.v2)],
platforms: [.macOS(.v26), .iOS(.v26), .watchOS(.v26), .tvOS(.v26), .visionOS(.v26)],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The DOF format is documented at <https://www.faa.gov/air_traffic/flight_info/aer
## Requirements

- Swift 6.2+
- macOS 15+, iOS 18+, watchOS 11+, tvOS 18+, or visionOS 2+
- macOS 26+, iOS 26+, watchOS 26+, tvOS 26+, or visionOS 26+

## Installation

Expand Down
60 changes: 32 additions & 28 deletions Sources/SwiftDOF/Cycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ public struct Cycle: Sendable, Codable, Equatable, Hashable {
month: datum.month,
day: datum.day
)
return calendar.date(from: components)!
guard let date = calendar.date(from: components) else {
preconditionFailure("The DOF datum is not a valid Gregorian date")
}
return date
}

/// The currently effective cycle based on today's date.
Expand All @@ -42,34 +45,10 @@ public struct Cycle: Sendable, Codable, Equatable, Hashable {
public let day: UInt8

/// The cycle preceding this one (56 days earlier).
public var previous: Self? {
guard let firstDate,
let previousDate = Self.calendar.date(byAdding: .day, value: -Self.period, to: firstDate)
else {
return nil
}
let components = Self.calendar.dateComponents([.year, .month, .day], from: previousDate)
return Self(
year: UInt(components.year!),
month: UInt8(components.month!),
day: UInt8(components.day!)
)
}
public var previous: Self? { cycle(offsetByDays: -Self.period) }

/// The cycle following this one (56 days later).
public var next: Self? {
guard let firstDate,
let nextDate = Self.calendar.date(byAdding: .day, value: Self.period, to: firstDate)
else {
return nil
}
let components = Self.calendar.dateComponents([.year, .month, .day], from: nextDate)
return Self(
year: UInt(components.year!),
month: UInt8(components.month!),
day: UInt8(components.day!)
)
}
public var next: Self? { cycle(offsetByDays: Self.period) }

/// Whether this cycle falls on a valid cycle boundary.
///
Expand Down Expand Up @@ -213,6 +192,31 @@ public struct Cycle: Sendable, Codable, Equatable, Hashable {

self.init(year: UInt(year), month: UInt8(month), day: UInt8(day))
}

/// Creates a cycle from date components that already fall on a cycle boundary.
///
/// - Parameter components: Components carrying a year, month, and day.
private init?(dateComponents components: DateComponents) {
guard let year = components.year,
let month = components.month,
let day = components.day
else {
return nil
}
self.init(year: UInt(year), month: UInt8(month), day: UInt8(day))
}

/// The cycle whose start date is `days` away from this one's.
private func cycle(offsetByDays days: Int) -> Self? {
guard let firstDate,
let shiftedDate = Self.calendar.date(byAdding: .day, value: days, to: firstDate)
else {
return nil
}
return Self(
dateComponents: Self.calendar.dateComponents([.year, .month, .day], from: shiftedDate)
)
}
}

extension Cycle: LosslessStringConvertible {
Expand All @@ -237,7 +241,7 @@ extension Cycle: Comparable {
extension Cycle: Identifiable {
/// The unique identifier for this cycle in YYYYMMDD format.
public var id: String {
String(format: "%04d%02d%02d", year, month, day)
unsafe String(format: "%04d%02d%02d", year, month, day)
}
}

Expand Down
64 changes: 54 additions & 10 deletions Sources/SwiftDOF/DOF.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public struct DOF: Sendable, Codable {
data: Data,
progressHandler: @Sendable (Progress) -> Void = { _ in },
errorCallback: ((any Error, Int) -> Void)? = nil
) throws {
) throws(DOFError) {
var obstacles: [String: Obstacle] = [:]
obstacles.reserveCapacity(Self.estimatedObstacleCount)

Expand Down Expand Up @@ -84,7 +84,7 @@ public struct DOF: Sendable, Codable {
url: URL,
progressHandler: @Sendable (Progress) -> Void = { _ in },
errorCallback: ((any Error, Int) -> Void)? = nil
) async throws {
) async throws(DOFError) {
var obstacles: [String: Obstacle] = [:]
obstacles.reserveCapacity(Self.estimatedObstacleCount)

Expand Down Expand Up @@ -180,14 +180,55 @@ public struct DOF: Sendable, Codable {
self.obstaclesByID = obstacles
}

/// Creates a DOF container by streaming a file from disk without holding it all in memory.
private init(
streamingFrom url: URL,
progressHandler: @Sendable (Progress) -> Void,
errorCallback: ((any Error, Int) -> Void)?
) throws(DOFError) {
var obstacles: [String: Obstacle] = [:]
obstacles.reserveCapacity(Self.estimatedObstacleCount)

var lineNumber = 0
var cycle: Cycle?

var reader = FileLineReader(url: url)

// Setup progress tracking based on file size
let progress = Progress(totalUnitCount: reader.fileSize ?? -1)
progressHandler(progress)

while let line = try reader.next() {
lineNumber += 1
try Self.processLine(
line[...],
lineNumber: lineNumber,
cycle: &cycle,
obstacles: &obstacles,
errorCallback: errorCallback
)
progress.completedUnitCount = reader.bytesRead
}
if progress.totalUnitCount > 0 {
progress.completedUnitCount = progress.totalUnitCount
}

guard let cycle else {
throw DOFError.invalidFormat(.missingCurrencyDate)
}

self.cycle = cycle
self.obstaclesByID = obstacles
}

/// Process a single line from the DOF file.
private static func processLine(
_ line: ArraySlice<UInt8>,
lineNumber: Int,
cycle: inout Cycle?,
obstacles: inout [String: Obstacle],
errorCallback: ((any Error, Int) -> Void)?
) throws {
) throws(DOFError) {
// Line 1: Parse currency date
if lineNumber == 1 {
cycle = try DOFByteParser.parseCurrencyDate(line)
Expand All @@ -213,22 +254,25 @@ public struct DOF: Sendable, Codable {

// MARK: - Static Factory Methods

/// Load DOF data from a file path (synchronous).
/// Load DOF data from a file path (synchronous), streaming the file from disk.
///
/// - Parameters:
/// - filePath: The URL of the DOF file.
/// - progressHandler: Optional callback called before processing begins with a Progress
/// object that you can use to track parsing progress.
/// - errorCallback: Optional callback for parse errors.
/// - Returns: The parsed DOF.
/// - Throws: Error if loading or parsing fails.
/// - Throws: ``DOFError`` if loading or parsing fails.
public static func from(
filePath: URL,
progressHandler: @Sendable (Progress) -> Void = { _ in },
errorCallback: ((any Error, Int) -> Void)? = nil
) throws -> Self {
let data = try Data(contentsOf: filePath)
return try Self(data: data, progressHandler: progressHandler, errorCallback: errorCallback)
) throws(DOFError) -> Self {
try Self(
streamingFrom: filePath,
progressHandler: progressHandler,
errorCallback: errorCallback
)
}

/// Load DOF data from raw data.
Expand All @@ -244,7 +288,7 @@ public struct DOF: Sendable, Codable {
data: Data,
progressHandler: @Sendable (Progress) -> Void = { _ in },
errorCallback: ((any Error, Int) -> Void)? = nil
) throws -> Self {
) throws(DOFError) -> Self {
try Self(data: data, progressHandler: progressHandler, errorCallback: errorCallback)
}

Expand All @@ -261,7 +305,7 @@ public struct DOF: Sendable, Codable {
url: URL,
progressHandler: @Sendable (Progress) -> Void = { _ in },
errorCallback: ((any Error, Int) -> Void)? = nil
) async throws -> Self {
) async throws(DOFError) -> Self {
try await Self(url: url, progressHandler: progressHandler, errorCallback: errorCallback)
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftDOF/Parser/ByteParsing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ extension RandomAccessCollection where Element == UInt8, Index == Int {
/// Convert to trimmed String (only when actually needed).
/// - Throws: DOFError.invalidEncoding if bytes cannot be decoded as Latin-1.
@inlinable
func toString() throws -> String {
func toString() throws(DOFError) -> String {
guard let string = String(bytes: Array(self), encoding: .isoLatin1) else {
throw DOFError.invalidEncoding
}
Expand Down
Loading