From 99e7525444e5940b0bceb1bcdc4626e87f26654e Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 14 Sep 2026 13:03:47 -0700 Subject: [PATCH 1/5] Modernize for Swift 6.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt typed throws across the parsing surface: `DOF.init(data:)`, `DOF.init(url:)`, the `DOF.from(…)` factories, `DOFByteParser`, and the DOF file line reader's `AsyncIteratorProtocol.Failure` now carry `DOFError`, while `AsyncBytesLineReader` propagates its source sequence's own `Failure` type. Extract the chunked file reader into `FileLineReader` so both `AsyncDOFLineReader` and the new synchronous streaming initializer share one implementation, and route `DOF.from(filePath:)` through it instead of reading the whole file into memory. Match the "CURRENCY DATE = " header marker against an `InlineArray<16, UInt8>`, removing a heap allocation and the crash on input shorter than the marker. Replace the force-unwraps in `Cycle.previous`, `Cycle.next`, and the cycle datum date with a shared failable helper and a precondition. Add Swift 6.4 CI legs alongside the newest leg for each OS already in the matrix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EbZbx5D2gGeXT8UxuiKEdq --- .github/workflows/ci.yml | 8 +- CHANGELOG.md | 12 ++ Sources/SwiftDOF/Cycle.swift | 58 +++---- Sources/SwiftDOF/DOF.swift | 64 ++++++-- Sources/SwiftDOF/Parser/ByteParsing.swift | 2 +- Sources/SwiftDOF/Parser/DOFByteParser.swift | 56 ++++--- Sources/SwiftDOF/Parser/DOFLineReader.swift | 166 ++++++++++++-------- Tests/SwiftDOFTests/DOFTests.swift | 58 +++++++ 8 files changed, 295 insertions(+), 129 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b219a92..199231a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index bd53185..0dcc62c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/SwiftDOF/Cycle.swift b/Sources/SwiftDOF/Cycle.swift index e087ea9..6600b8b 100644 --- a/Sources/SwiftDOF/Cycle.swift +++ b/Sources/SwiftDOF/Cycle.swift @@ -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. @@ -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. /// @@ -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 { diff --git a/Sources/SwiftDOF/DOF.swift b/Sources/SwiftDOF/DOF.swift index 2bc231b..fcc0e06 100644 --- a/Sources/SwiftDOF/DOF.swift +++ b/Sources/SwiftDOF/DOF.swift @@ -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) @@ -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) @@ -180,6 +180,47 @@ 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, @@ -187,7 +228,7 @@ public struct DOF: Sendable, Codable { 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) @@ -213,7 +254,7 @@ 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. @@ -221,14 +262,17 @@ public struct DOF: Sendable, Codable { /// 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. @@ -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) } @@ -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) } diff --git a/Sources/SwiftDOF/Parser/ByteParsing.swift b/Sources/SwiftDOF/Parser/ByteParsing.swift index 5fd37ce..b652705 100644 --- a/Sources/SwiftDOF/Parser/ByteParsing.swift +++ b/Sources/SwiftDOF/Parser/ByteParsing.swift @@ -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 } diff --git a/Sources/SwiftDOF/Parser/DOFByteParser.swift b/Sources/SwiftDOF/Parser/DOFByteParser.swift index 765eac1..86bfeb7 100644 --- a/Sources/SwiftDOF/Parser/DOFByteParser.swift +++ b/Sources/SwiftDOF/Parser/DOFByteParser.swift @@ -34,8 +34,13 @@ struct DOFByteParser: Sendable { /// Minimum line length required for parsing. static let minimumLineLength = 127 - /// Pattern to match in currency date header. - private static let currencyDatePattern: [UInt8] = Array("CURRENCY DATE = ".utf8) + /// The bytes of "CURRENCY DATE = ", the pattern preceding the date in the DOF header. + private static let currencyDatePattern: InlineArray<16, UInt8> = [ + UInt8(ascii: "C"), UInt8(ascii: "U"), UInt8(ascii: "R"), UInt8(ascii: "R"), + UInt8(ascii: "E"), UInt8(ascii: "N"), UInt8(ascii: "C"), UInt8(ascii: "Y"), + UInt8(ascii: " "), UInt8(ascii: "D"), UInt8(ascii: "A"), UInt8(ascii: "T"), + UInt8(ascii: "E"), UInt8(ascii: " "), UInt8(ascii: "="), UInt8(ascii: " ") + ] // MARK: Public API @@ -43,7 +48,7 @@ struct DOFByteParser: Sendable { static func parseLine( _ bytes: T, lineNumber: Int = 0 - ) throws -> Obstacle where T.Element == UInt8, T.Index == Int { + ) throws(DOFError) -> Obstacle where T.Element == UInt8, T.Index == Int { guard bytes.count >= minimumLineLength else { throw DOFError.lineTooShort( expected: minimumLineLength, @@ -170,26 +175,12 @@ struct DOFByteParser: Sendable { /// Expects format: "CURRENCY DATE = MM/DD/YY" static func parseCurrencyDate( _ bytes: T - ) throws -> Cycle where T.Element == UInt8, T.Index == Int { - // Find pattern - var matchStart: Int? - for i in bytes.startIndex..<(bytes.endIndex - currencyDatePattern.count) { - let slice = bytes[i..<(i + currencyDatePattern.count)] - guard zip(slice, currencyDatePattern).allSatisfy({ $0 == $1 }) else { continue } - matchStart = i + currencyDatePattern.count - break - } - - guard let start = matchStart else { + ) throws(DOFError) -> Cycle where T.Element == UInt8, T.Index == Int { + guard let start = currencyDateStart(in: bytes) else { throw DOFError.invalidFormat(.currencyDateHeaderNotFound) } - // Find slash positions in date portion - let dateBytes = bytes[start...] - var slashPositions: [Int] = [] - for (i, byte) in dateBytes.enumerated() where byte == ASCII.slash { - slashPositions.append(start + i) - } + let slashPositions = (start..= 2 else { throw DOFError.invalidFormat(.invalidCurrencyDateFormat) @@ -212,6 +203,25 @@ struct DOFByteParser: Sendable { // MARK: Private Helpers + /// The index just past the currency date pattern, or `nil` when the pattern is absent. + private static func currencyDateStart( + in bytes: T + ) -> Int? where T.Element == UInt8, T.Index == Int { + let patternLength = currencyDatePattern.count + guard bytes.count >= patternLength else { return nil } + + return (bytes.startIndex..<(bytes.endIndex - patternLength)) + .first { matchesCurrencyDatePattern(bytes, at: $0) } + .map { $0 + patternLength } + } + + private static func matchesCurrencyDatePattern( + _ bytes: T, + at start: Int + ) -> Bool where T.Element == UInt8, T.Index == Int { + currencyDatePattern.indices.allSatisfy { bytes[start + $0] == currencyDatePattern[$0] } + } + private static func slice( _ bytes: T, _ base: Int, @@ -224,7 +234,7 @@ struct DOFByteParser: Sendable { _ bytes: T, base: Int, lineNumber: Int - ) throws -> Double where T.Element == UInt8, T.Index == Int { + ) throws(DOFError) -> Double where T.Element == UInt8, T.Index == Int { let degSlice = slice(bytes, base, fields.latDegrees) let minSlice = slice(bytes, base, fields.latMinutes) let secSlice = slice(bytes, base, fields.latSeconds) @@ -268,7 +278,7 @@ struct DOFByteParser: Sendable { _ bytes: T, base: Int, lineNumber: Int - ) throws -> Double where T.Element == UInt8, T.Index == Int { + ) throws(DOFError) -> Double where T.Element == UInt8, T.Index == Int { let degSlice = slice(bytes, base, fields.lonDegrees) let minSlice = slice(bytes, base, fields.lonMinutes) let secSlice = slice(bytes, base, fields.lonSeconds) @@ -314,7 +324,7 @@ struct DOFByteParser: Sendable { private static func parseJulianDate( _ bytes: T, lineNumber: Int - ) throws -> DateComponents where T.Element == UInt8, T.Index == Int { + ) throws(DOFError) -> DateComponents where T.Element == UInt8, T.Index == Int { // Format: YYYYDDD (e.g., 2014138 = year 2014, day 138) let yearSlice = bytes.prefix(4) let daySlice = bytes.dropFirst(4) diff --git a/Sources/SwiftDOF/Parser/DOFLineReader.swift b/Sources/SwiftDOF/Parser/DOFLineReader.swift index 511902b..e78eaa9 100644 --- a/Sources/SwiftDOF/Parser/DOFLineReader.swift +++ b/Sources/SwiftDOF/Parser/DOFLineReader.swift @@ -45,13 +45,11 @@ struct DOFLineReader: Sequence, IteratorProtocol, Sendable { } } -// MARK: - AsyncDOFLineReader - -/// Async line reader for streaming DOF data from a file URL. -/// Reads in chunks to minimize memory usage for large files. -struct AsyncDOFLineReader: AsyncSequence, Sendable { - typealias Element = [UInt8] +// MARK: - FileLineReader +/// Line reader that streams DOF data from a file on disk. +/// Reads in chunks so a large file is never held in memory in its entirety. +struct FileLineReader: Sendable { /// Default read buffer size (64KB). static let defaultBufferSize = 65536 @@ -60,83 +58,120 @@ struct AsyncDOFLineReader: AsyncSequence, Sendable { private let url: URL private let bufferSize: Int + private var handle: FileHandle? + private var buffer: [UInt8] = [] + private var bufferPosition = 0 + private var lineBuffer: [UInt8] = [] + private var isAtEnd = false /// The total size of the file in bytes, if known. let fileSize: Int64? + /// Total bytes read from the file so far. + private(set) var bytesRead: Int64 = 0 + + private var bufferIsExhausted: Bool { bufferPosition >= buffer.count } + init(url: URL, bufferSize: Int = defaultBufferSize) { self.url = url self.bufferSize = bufferSize - // Try to get file size for progress tracking - if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), - let size = attrs[.size] as? Int64 - { - self.fileSize = size - } else { - self.fileSize = nil - } + self.fileSize = Self.sizeOfFile(at: url) + lineBuffer.reserveCapacity(Self.lineBufferCapacity) } - func makeAsyncIterator() -> AsyncIterator { - AsyncIterator(url: url, bufferSize: bufferSize) + /// The size in bytes of the file at `url`, if it can be determined. + static func sizeOfFile(at url: URL) -> Int64? { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { + return nil + } + return attributes[.size] as? Int64 } - struct AsyncIterator: AsyncIteratorProtocol { - private let url: URL - private let bufferSize: Int - private var handle: FileHandle? - private var buffer: [UInt8] = [] - private var bufferPos: Int = 0 - private var lineBuffer: [UInt8] = [] - private var isEOF = false + /// Returns the next line, or `nil` once the file is exhausted. + mutating func next() throws(DOFError) -> [UInt8]? { + guard !isAtEnd else { return nil } - /// Total bytes read from the file so far. - private(set) var bytesRead: Int64 = 0 + let handle = try openedHandle() + lineBuffer.removeAll(keepingCapacity: true) - init(url: URL, bufferSize: Int) { - self.url = url - self.bufferSize = bufferSize - self.lineBuffer.reserveCapacity(lineBufferCapacity) - } + while true { + if bufferIsExhausted { + guard let chunk = try readChunk(from: handle), !chunk.isEmpty else { + isAtEnd = true + return lineBuffer.isEmpty ? nil : lineBuffer + } + bytesRead += Int64(chunk.count) + buffer = Array(chunk) + bufferPosition = 0 + } - mutating func next() throws -> [UInt8]? { - guard !isEOF else { return nil } + let byte = buffer[bufferPosition] + bufferPosition += 1 - // Lazily open file handle on first call - if handle == nil { - handle = try FileHandle(forReadingFrom: url) + if byte == ASCII.LF { + // Strip trailing CR if present (handles CRLF) + if lineBuffer.last == ASCII.CR { + lineBuffer.removeLast() + } + return lineBuffer } - guard let handle else { preconditionFailure("handle was nil") } + lineBuffer.append(byte) + } + } - lineBuffer.removeAll(keepingCapacity: true) + private mutating func openedHandle() throws(DOFError) -> FileHandle { + if let handle { return handle } + guard let opened = try? FileHandle(forReadingFrom: url) else { + throw DOFError.fileNotFound(url) + } + handle = opened + return opened + } - while true { - // Refill buffer if exhausted - if bufferPos >= buffer.count { - guard let chunk = try handle.read(upToCount: bufferSize), - !chunk.isEmpty - else { - isEOF = true - // Return any remaining content as final line - return lineBuffer.isEmpty ? nil : lineBuffer - } - bytesRead += Int64(chunk.count) - buffer = Array(chunk) - bufferPos = 0 - } + private func readChunk(from handle: FileHandle) throws(DOFError) -> Data? { + do { + return try handle.read(upToCount: bufferSize) + } catch { + throw DOFError.streamError(error) + } + } +} - let byte = buffer[bufferPos] - bufferPos += 1 +// MARK: - AsyncDOFLineReader - if byte == ASCII.LF { - // Strip trailing CR if present (handles CRLF) - if lineBuffer.last == ASCII.CR { - lineBuffer.removeLast() - } - return lineBuffer - } - lineBuffer.append(byte) - } +/// Async façade over ``FileLineReader`` for `for await` iteration of a DOF file. +struct AsyncDOFLineReader: AsyncSequence, Sendable { + typealias Element = [UInt8] + typealias Failure = DOFError + + private let url: URL + private let bufferSize: Int + + /// The total size of the file in bytes, if known. + let fileSize: Int64? + + init(url: URL, bufferSize: Int = FileLineReader.defaultBufferSize) { + self.url = url + self.bufferSize = bufferSize + self.fileSize = FileLineReader.sizeOfFile(at: url) + } + + func makeAsyncIterator() -> AsyncIterator { + AsyncIterator(reader: FileLineReader(url: url, bufferSize: bufferSize)) + } + + struct AsyncIterator: AsyncIteratorProtocol { + private var reader: FileLineReader + + /// Total bytes read from the file so far. + var bytesRead: Int64 { reader.bytesRead } + + init(reader: FileLineReader) { + self.reader = reader + } + + mutating func next() throws(DOFError) -> [UInt8]? { + try reader.next() } } } @@ -148,6 +183,7 @@ struct AsyncDOFLineReader: AsyncSequence, Sendable { struct AsyncBytesLineReader: AsyncSequence, Sendable where Source.Element == UInt8, Source: Sendable { typealias Element = [UInt8] + typealias Failure = Source.Failure /// Pre-allocated capacity for line buffer (DOF lines are ~128 bytes). private static var lineBufferCapacity: Int { 256 } @@ -172,10 +208,10 @@ where Source.Element == UInt8, Source: Sendable { } @concurrent - mutating func next() async throws -> [UInt8]? { + mutating func next() async throws(Source.Failure) -> [UInt8]? { lineBuffer.removeAll(keepingCapacity: true) - while let byte = try await iterator.next() { + while let byte = try await iterator.next(isolation: nil) { if byte == ASCII.LF { // Strip trailing CR if present (handles CRLF) if lineBuffer.last == ASCII.CR { diff --git a/Tests/SwiftDOFTests/DOFTests.swift b/Tests/SwiftDOFTests/DOFTests.swift index 1c1ad44..e49ffb1 100644 --- a/Tests/SwiftDOFTests/DOFTests.swift +++ b/Tests/SwiftDOFTests/DOFTests.swift @@ -23,6 +23,11 @@ struct DOFTests { sampleDOFContent.data(using: .utf8)! } + /// A URL in the temporary directory that no file occupies. + private static func temporaryFileURL() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).dat") + } + @Test func `parses obstacles and the currency date from DOF data`() throws { let dof = try DOF(data: sampleDOFData) @@ -172,6 +177,41 @@ struct DOFTests { #expect(dof.count == 3) } + @Test + func `parses obstacles streamed from a file on disk`() throws { + let dof = try withTemporaryFile(containing: sampleDOFContent) { + try DOF.from(filePath: $0) + } + + #expect(dof.count == 3) + #expect(dof.cycle.year == 2025) + } + + @Test(arguments: [1, 7, 64, 4096]) + func `reads the same lines from a file whatever the buffer size`(_ bufferSize: Int) throws { + let lines = try withTemporaryFile(containing: sampleDOFContent) { url in + var reader = FileLineReader(url: url, bufferSize: bufferSize) + var lines: [[UInt8]] = [] + while let line = try reader.next() { + lines.append(line) + } + return lines + } + + #expect(lines == sampleDOFContent.split(separator: "\n").map { Array($0.utf8) }) + } + + @Test + func `throws a file-not-found error for a missing file`() { + let missingFile = Self.temporaryFileURL() + + let error = #expect(throws: DOFError.self) { + try DOF.from(filePath: missingFile) + } + + #expect(error?.isFileNotFound == true) + } + @Test func `leaves the error callback uncalled for valid data`() throws { var errorCalled = false @@ -186,4 +226,22 @@ struct DOFTests { #expect(dof.count == 3) #expect(!errorCalled) // No errors in valid content } + + /// Writes `content` to a temporary file, hands its URL to `body`, and removes the file after. + private func withTemporaryFile( + containing content: String, + _ body: (URL) throws -> T + ) throws -> T { + let url = Self.temporaryFileURL() + try content.write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + return try body(url) + } +} + +extension DOFError { + fileprivate var isFileNotFound: Bool { + if case .fileNotFound = self { return true } + return false + } } From 0c2827916e82c7ed10793a1c8dc50816b9beafed Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 14 Sep 2026 14:53:07 -0700 Subject: [PATCH 2/5] Drop the unused bytesRead forward on the line iterator Extracting the chunked reader out of the async iterator left `AsyncIterator.bytesRead` forwarding to a reader nothing asks it about; progress reporting reads `FileLineReader.bytesRead` directly. Periphery flags it, failing the strict scan. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EbZbx5D2gGeXT8UxuiKEdq --- Sources/SwiftDOF/Parser/DOFLineReader.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/SwiftDOF/Parser/DOFLineReader.swift b/Sources/SwiftDOF/Parser/DOFLineReader.swift index e78eaa9..c195d93 100644 --- a/Sources/SwiftDOF/Parser/DOFLineReader.swift +++ b/Sources/SwiftDOF/Parser/DOFLineReader.swift @@ -163,9 +163,6 @@ struct AsyncDOFLineReader: AsyncSequence, Sendable { struct AsyncIterator: AsyncIteratorProtocol { private var reader: FileLineReader - /// Total bytes read from the file so far. - var bytesRead: Int64 { reader.bytesRead } - init(reader: FileLineReader) { self.reader = reader } From 456733217bfd2be4c4bd0da160668e61a1ebcf18 Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 14 Sep 2026 16:06:34 -0700 Subject: [PATCH 3/5] Keep the macOS 26 floor for the InlineArray work `main` lowered this package's floor to what its code there requires. The byte-parsing work on this branch uses `InlineArray`, which is macOS 26, so the branch declares the floor its own code needs. Merging this therefore raises the floor. That is the trade the branch asks for and it should be decided on the merge, not worked around in the source. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EbZbx5D2gGeXT8UxuiKEdq --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 71bf0e7..2701820 100644 --- a/Package.swift +++ b/Package.swift @@ -15,7 +15,7 @@ let upcomingFeatures: [SwiftSetting] = [ 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( From eb3b5865c0832dcd3a716708e227702735e6ce1c Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 14 Sep 2026 18:01:52 -0700 Subject: [PATCH 4/5] Adopt strict memory safety Enable `.strictMemorySafety()` (SE-0458) alongside the existing upcoming feature flags and audit every unsafe construct it surfaces, marking each with the `unsafe` expression marker. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EbZbx5D2gGeXT8UxuiKEdq --- Package.swift | 3 ++- Sources/SwiftDOF/Cycle.swift | 2 +- Sources/SwiftDOF/Parser/DOFLineReader.swift | 6 +++--- Sources/SwiftDOF_E2E/OutputFormatter.swift | 14 ++++++++------ Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift | 2 +- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/Package.swift b/Package.swift index 2701820..fbab8a3 100644 --- a/Package.swift +++ b/Package.swift @@ -9,7 +9,8 @@ let upcomingFeatures: [SwiftSetting] = [ .enableUpcomingFeature("ImmutableWeakCaptures"), .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("InternalImportsByDefault") + .enableUpcomingFeature("InternalImportsByDefault"), + .strictMemorySafety() ] let package = Package( diff --git a/Sources/SwiftDOF/Cycle.swift b/Sources/SwiftDOF/Cycle.swift index 6600b8b..c5013e6 100644 --- a/Sources/SwiftDOF/Cycle.swift +++ b/Sources/SwiftDOF/Cycle.swift @@ -241,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) } } diff --git a/Sources/SwiftDOF/Parser/DOFLineReader.swift b/Sources/SwiftDOF/Parser/DOFLineReader.swift index c195d93..34af64e 100644 --- a/Sources/SwiftDOF/Parser/DOFLineReader.swift +++ b/Sources/SwiftDOF/Parser/DOFLineReader.swift @@ -26,10 +26,10 @@ struct DOFLineReader: Sequence, IteratorProtocol, Sendable { lineBuffer.removeAll(keepingCapacity: true) // Scan until LF or end of data - data.withUnsafeBytes { buffer in - let bytes = buffer.bindMemory(to: UInt8.self) + unsafe data.withUnsafeBytes { buffer in + let bytes = unsafe buffer.bindMemory(to: UInt8.self) while position < bytes.count { - let byte = bytes[position] + let byte = unsafe bytes[position] position += 1 if byte == ASCII.LF { return } lineBuffer.append(byte) diff --git a/Sources/SwiftDOF_E2E/OutputFormatter.swift b/Sources/SwiftDOF_E2E/OutputFormatter.swift index 0c6f5c4..1f79e19 100644 --- a/Sources/SwiftDOF_E2E/OutputFormatter.swift +++ b/Sources/SwiftDOF_E2E/OutputFormatter.swift @@ -17,9 +17,10 @@ protocol OutputFormatter { extension OutputStream { func write(_ string: String) { guard let data = string.data(using: .utf8) else { return } - data.withUnsafeBytes { buffer in - guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } - write(pointer, maxLength: buffer.count) + unsafe data.withUnsafeBytes { buffer in + guard let pointer = unsafe buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { return } + unsafe write(pointer, maxLength: buffer.count) } } @@ -48,9 +49,10 @@ struct JSONOutputFormatter: OutputFormatter { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let jsonData = try encoder.encode(dof.all) - jsonData.withUnsafeBytes { buffer in - guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } - stream.write(pointer, maxLength: buffer.count) + unsafe jsonData.withUnsafeBytes { buffer in + guard let pointer = unsafe buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { return } + unsafe stream.write(pointer, maxLength: buffer.count) } } } diff --git a/Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift b/Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift index 9d87e91..9aeb685 100644 --- a/Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift +++ b/Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift @@ -42,7 +42,7 @@ struct SwiftDOF_E2E: AsyncParsableCommand { else { fatalError("Current cycle could not be determined") } - let filename = String(format: "DOF_%02d%02d%02d.zip", year % 100, month, day) + let filename = unsafe String(format: "DOF_%02d%02d%02d.zip", year % 100, month, day) guard let url = URL(string: "https://aeronav.faa.gov/Obst_Data/\(filename)") else { fatalError("Current DOF URL could not be determined") } From cb49b60833314f6b3db0e645b016c4c3862f88ff Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 14 Sep 2026 20:37:57 -0700 Subject: [PATCH 5/5] Match the README to this branch's raised floor `main` lowered the floor to macOS 15 and the README followed. This branch raises it to 26 for `InlineArray`, so the README has to say so too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EbZbx5D2gGeXT8UxuiKEdq --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2f4b108..7e795fc 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The DOF format is documented at