diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..f458b6d --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +# xcode-27 is a GitHub-hosted macOS 27 image published as a public preview +# (actions/runner-images#14404). It is absent from actionlint's built-in label +# list, so it is declared here to keep runner-label checking enabled. +self-hosted-runner: + labels: + - xcode-27 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fd7213..faf7be7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,19 @@ # CI Matrix Policy # -# Category A (tools-version 6.2 + macOS 26 min): macos-26 + Swift 6.2 -# Category B (tools-version 6.2 + older macOS): macos-15 + 6.2, macos-26 + 6.2 -# Category C (tools-version 6.3): macos-15 + 6.3, macos-26 + 6.3 -# Linux: ubuntu + Swift 6.3 +# This package adopts Foundation's SF-0023 `ProgressManager`, which exists only +# in the macOS 27 SDK family and, on Linux, only in swift-foundation 6.4. That +# fixes the whole matrix: # -# This package is Category C. StreamingCSV requires tools-version 6.3, so Swift -# 6.1 and 6.2 can no longer resolve it. +# Darwin: the macOS 27 SDK ships with Xcode 27, which the `xcode-27` image +# provides and `macos-15`/`macos-26` cannot. Its bundled toolchain is +# Swift 6.4, so the Xcode selection is the whole toolchain setup and +# SwiftyLab/setup-swift is not used here. +# Linux: Swift 6.4 only. `ProgressManager` is `@available(FoundationPreview +# 6.4)`, so it is absent from swift-foundation release/6.3. # -# When Swift 6.4 ships: add 6.4 legs alongside 6.3 +# `// swift-tools-version:` is 6.4 because `MacOSVersion.v27` is +# `@available(_PackageDescription 6.4)`, so no earlier toolchain can even read +# the manifest. name: CI @@ -23,19 +28,35 @@ concurrency: cancel-in-progress: true jobs: - tests: - name: Build and Test + tests-darwin: + name: Build and Test (macOS) + runs-on: xcode-27 + steps: + - uses: actions/checkout@v6 + # `latest` rather than `latest-stable`: the macOS 27 SDK is what this + # package needs, and it ships with an Xcode that may still be a preview. + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest + - name: Cache SwiftPM build products + uses: actions/cache@v6 + with: + path: | + .build + ~/.cache/org.swift.swiftpm + ~/Library/Caches/org.swift.swiftpm + key: swiftpm-test-xcode-27-${{ hashFiles('Package.resolved') }} + - name: Build + run: swift build -v + - name: Test + run: swift test -v + tests-linux: + name: Build and Test (Linux) strategy: fail-fast: false matrix: - include: - - os: macos-15 - swift: "6.3" - - os: macos-26 - swift: "6.3" - - os: ubuntu-latest - swift: "6.3" - runs-on: ${{ matrix.os }} + swift: ["6.4"] + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: SwiftyLab/setup-swift@latest @@ -48,7 +69,7 @@ jobs: .build ~/.cache/org.swift.swiftpm ~/Library/Caches/org.swift.swiftpm - key: swiftpm-test-${{ matrix.os }}-swift${{ matrix.swift }}-${{ hashFiles('Package.resolved') }} + key: swiftpm-test-ubuntu-latest-swift${{ matrix.swift }}-${{ hashFiles('Package.resolved') }} - name: Build run: swift build -v - name: Test @@ -57,12 +78,12 @@ jobs: name: Build Documentation # macOS: the CoreLocation-gated Location extension is absent from the # Linux symbol graph, so its documentation would go unchecked there. - runs-on: macos-26 + runs-on: xcode-27 steps: - uses: actions/checkout@v6 - - uses: SwiftyLab/setup-swift@latest + - uses: maxim-lobanov/setup-xcode@v1 with: - swift-version: "6.3" + xcode-version: latest - name: Cache SwiftPM build products uses: actions/cache@v6 with: @@ -70,7 +91,7 @@ jobs: .build ~/.cache/org.swift.swiftpm ~/Library/Caches/org.swift.swiftpm - key: swiftpm-docs-macos-26-swift6.3-${{ hashFiles('Package.resolved') }} + key: swiftpm-docs-xcode-27-${{ hashFiles('Package.resolved') }} - name: Build documentation run: | swift package \ diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index fc9dc2c..97c593c 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -19,12 +19,13 @@ jobs: name: Generate Documentation # macOS: the CoreLocation-gated Location extension is absent from the # Linux symbol graph, so its documentation would be missing there. - runs-on: macos-26 + # xcode-27: the package's macOS 27 floor needs the macOS 27 SDK to build. + runs-on: xcode-27 steps: - uses: actions/checkout@v6 - - uses: SwiftyLab/setup-swift@latest + - uses: maxim-lobanov/setup-xcode@v1 with: - swift-version: "6.3" + xcode-version: latest - name: Cache SwiftPM build products uses: actions/cache@v6 with: @@ -32,7 +33,7 @@ jobs: .build ~/.cache/org.swift.swiftpm ~/Library/Caches/org.swift.swiftpm - key: swiftpm-docs-macos-26-swift6.3-${{ hashFiles('Package.resolved') }} + key: swiftpm-docs-xcode-27-${{ hashFiles('Package.resolved') }} - name: Build run: | swift package \ diff --git a/.github/workflows/periphery.yml b/.github/workflows/periphery.yml index 51081f3..b3fd923 100644 --- a/.github/workflows/periphery.yml +++ b/.github/workflows/periphery.yml @@ -6,15 +6,16 @@ on: jobs: periphery: name: Run Periphery - runs-on: macos-latest + # xcode-27: scanning builds the package, which needs the macOS 27 SDK. + runs-on: xcode-27 if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository permissions: contents: read steps: - uses: actions/checkout@v6 - - uses: SwiftyLab/setup-swift@latest + - uses: maxim-lobanov/setup-xcode@v1 with: - swift-version: "6.3" + xcode-version: latest - name: Cache SwiftPM build products uses: actions/cache@v6 with: @@ -22,7 +23,7 @@ jobs: .build ~/.cache/org.swift.swiftpm ~/Library/Caches/org.swift.swiftpm - key: swiftpm-periphery-macos-latest-swift6.3-${{ hashFiles('Package.resolved') }} + key: swiftpm-periphery-xcode-27-${{ hashFiles('Package.resolved') }} - name: Install Periphery run: brew install periphery-pro/tap/periphery-cli - name: Run Periphery diff --git a/CHANGELOG.md b/CHANGELOG.md index 60aa11c..0184b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,57 @@ ## [Unreleased] +### Added + +- Progress reported for a file read, an archive extraction, or a download now also carries + `totalByteCount` and `completedByteCount`, so a caller can display byte figures alongside the + fraction. Read them from anywhere in your own progress tree with + `summary(of: \.completedByteCount)` + +### Changed + +- **BREAKING:** Progress is reported through Foundation's `ProgressManager` (SF-0023) rather than + `Progress`/`NSProgress`, which inverts how progress reaches SwiftNASR. Where you used to receive a + `Progress` through a handler block and add it to your own tree, you now pass a `Subprogress` down + from your own `ProgressManager`. Six signatures change accordingly, each replacing its + `withProgress:` handler with `progress: consuming Subprogress? = nil`: `NASR.load(progress:)`, + `NASR.parse(_:progress:errorHandler:)`, `Loader.load(progress:)`, `Downloader.load(progress:)`, + `Distribution.readFile(path:progress:returningLines:)`, and + `Distribution.readFileRaw(path:progress:)` — along with the `Distribution` conveniences + `read(type:progress:returningLines:)` and `readCSVFiles(for:progress:returningLines:)`. Pass `nil` + (or omit the argument) to track no progress: + + ``` swift + let progress = ProgressManager(totalCount: 100) + let nasr = NASR.fromInternetToMemory()! + try await nasr.load(progress: progress.subprogress(assigningCount: 10)) + try await nasr.parse( + .airports, + progress: progress.subprogress(assigningCount: 90), + errorHandler: { _ in .proceed } + ) + ``` + + A `Subprogress` is non-copyable and cannot be captured by an escaping closure, so code that parses + several record types concurrently must mint one inside each task from a `ProgressManager` it + captures, rather than creating them up front. +- **BREAKING:** The platform floor rises to macOS 27, iOS 27, tvOS 27, watchOS 27, and visionOS 27, + and the manifest's tools version to 6.4. `ProgressManager` exists nowhere below that: it carries no + lower availability annotation on Apple platforms, it is `@available(FoundationPreview 6.4)` on + Linux, and `MacOSVersion.v27` is itself `@available(_PackageDescription 6.4)` +- **BREAKING:** `CSVParser` no longer requires `progress` or `bytesRead`. Both were vestigial — the + progress object was written but never read, and `bytesRead` was only ever reset to zero — and a CSV + parse now reports its single unit of progress from `NASR.parse(_:progress:errorHandler:)` itself +- A record that fails to parse no longer stalls the fixed-width parse progress. Every line now counts + against the total, whether or not it parsed, so an observer waiting for completion is not left + hanging by a diagnosed record + +### Fixed + +- A directory distribution no longer double-counts a file it reads. It added each line's length to + its progress on top of the length of the chunk the line arrived in, reporting 35 bytes read from a + 21-byte file. `Progress` clamped `fractionCompleted` to 1.0 and hid this; `ProgressManager` + reports the overshoot ## [4.2.0] - 2026-09-14 ### Changed diff --git a/Package.swift b/Package.swift index 93d4722..a0b3760 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.3 +// swift-tools-version: 6.4 import PackageDescription @@ -8,13 +8,14 @@ let upcomingFeatures: [SwiftSetting] = [ .enableUpcomingFeature("ImmutableWeakCaptures"), .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("InternalImportsByDefault") + .enableUpcomingFeature("InternalImportsByDefault"), + .strictMemorySafety() ] let package = Package( name: "SwiftNASR", defaultLocalization: "en", - platforms: [.macOS(.v15), .iOS(.v18), .tvOS(.v18), .watchOS(.v11), .visionOS(.v2)], + platforms: [.macOS(.v27), .iOS(.v27), .tvOS(.v27), .watchOS(.v27), .visionOS(.v27)], products: [ .library( @@ -34,8 +35,7 @@ let package = Package( name: "SwiftNASR", dependencies: ["ZIPFoundation", "StreamingCSV"], resources: [.process("Resources")], - swiftSettings: upcomingFeatures, - linkerSettings: [.linkedLibrary("swift_Concurrency")] + swiftSettings: upcomingFeatures ), .testTarget( name: "SwiftNASRTests", @@ -44,8 +44,7 @@ let package = Package( .copy("Resources/MockDistribution"), .copy("Resources/FailingMockDistribution") ], - swiftSettings: upcomingFeatures, - linkerSettings: [.linkedLibrary("swift_Concurrency")] + swiftSettings: upcomingFeatures ), .executableTarget( name: "SwiftNASR_E2E", @@ -54,8 +53,7 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser") ], path: "Tests/SwiftNASR_E2E", - swiftSettings: upcomingFeatures, - linkerSettings: [.linkedLibrary("swift_Concurrency")] + swiftSettings: upcomingFeatures ) ], swiftLanguageModes: [.v5, .v6] diff --git a/README.md b/README.md index 5acb80b..dbbfb28 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ [![CI](https://github.com/RISCfuture/SwiftNASR/actions/workflows/ci.yml/badge.svg)](https://github.com/RISCfuture/SwiftNASR/actions/workflows/ci.yml) [![Documentation](https://github.com/RISCfuture/SwiftNASR/actions/workflows/doc.yml/badge.svg)](https://riscfuture.github.io/SwiftNASR/) -[![Swift 6.3+](https://img.shields.io/badge/Swift-6.3+-orange.svg)](https://swift.org) -[![Platforms](https://img.shields.io/badge/Platforms-macOS%20|%20iOS%20|%20tvOS%20|%20watchOS%20|%20visionOS-blue.svg)](https://swift.org) +[![Swift 6.4+](https://img.shields.io/badge/Swift-6.4+-orange.svg)](https://swift.org) +[![Platforms](https://img.shields.io/badge/Platforms-macOS%2027+%20|%20iOS%2027+%20|%20tvOS%2027+%20|%20watchOS%2027+%20|%20visionOS%2027+-blue.svg)](https://swift.org) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) SwiftNASR is a Swift library that downloads and parses National Airspace System diff --git a/Sources/SwiftNASR/Cycle.swift b/Sources/SwiftNASR/Cycle.swift index 036d019..50846fa 100644 --- a/Sources/SwiftNASR/Cycle.swift +++ b/Sources/SwiftNASR/Cycle.swift @@ -94,7 +94,7 @@ public struct Cycle: Codable, LosslessStringConvertible, Sendable, Identifiable, /// The cycle in YYYY-mm-dd format. public var description: String { - String(format: "%04d-%02d-%02d", year, month, day) + unsafe String(format: "%04d-%02d-%02d", year, month, day) } public var id: String { description } diff --git a/Sources/SwiftNASR/Distribution/ArchiveDataDistribution.swift b/Sources/SwiftNASR/Distribution/ArchiveDataDistribution.swift index f44537f..54d9ac1 100644 --- a/Sources/SwiftNASR/Distribution/ArchiveDataDistribution.swift +++ b/Sources/SwiftNASR/Distribution/ArchiveDataDistribution.swift @@ -1,5 +1,5 @@ public import Foundation -@preconcurrency import ZIPFoundation +@unsafe @preconcurrency import ZIPFoundation /** A NASR distribution that has been loaded from a ZIP archive and stored in @@ -44,7 +44,7 @@ public final class ArchiveDataDistribution: Distribution { @discardableResult private func readFileWithCallback( path: String, - withProgress progressHandler: (Progress) -> Void = { _ in }, + progress: ByteReadProgress, eachLine: (Data) -> Void ) throws -> UInt { // Try exact match first, then case-insensitive match @@ -54,12 +54,11 @@ public final class ArchiveDataDistribution: Distribution { var buffer = Data(capacity: Int(chunkSize)) var lines: UInt = 0 - let progress = Progress(totalUnitCount: Int64(entry.uncompressedSize)) - progressHandler(progress) + progress.setTotal(Int(clamping: entry.uncompressedSize)) _ = try archive.extract(entry, bufferSize: chunkSize, skipCRC32: false, progress: nil) { data in buffer.append(data) - progress.completedUnitCount += Int64(data.count) + progress.advance(by: data.count) // Handle both \r\n and \n line endings while true { let crlfRange = buffer.range(of: crlfDelimiter) @@ -98,12 +97,13 @@ public final class ArchiveDataDistribution: Distribution { public func readFile( path: String, - withProgress progressHandler: (Progress) -> Void = { _ in }, + progress: consuming Subprogress? = nil, returningLines linesHandler: (UInt) -> Void = { _ in } ) -> AsyncThrowingStream { + let progress = ByteReadProgress(progress) return AsyncThrowingStream { continuation in do { - let lines = try readFileWithCallback(path: path, withProgress: progressHandler) { data in + let lines = try readFileWithCallback(path: path, progress: progress) { data in continuation.yield(data) } linesHandler(lines) @@ -116,8 +116,9 @@ public final class ArchiveDataDistribution: Distribution { public func readFileRaw( path: String, - withProgress progressHandler: (Progress) -> Void = { _ in } + progress: consuming Subprogress? = nil ) -> AsyncThrowingStream { + let progress = ByteReadProgress(progress) return AsyncThrowingStream { continuation in do { // Try exact match first, then case-insensitive match @@ -128,12 +129,11 @@ public final class ArchiveDataDistribution: Distribution { return } - let progress = Progress(totalUnitCount: Int64(entry.uncompressedSize)) - progressHandler(progress) + progress.setTotal(Int(clamping: entry.uncompressedSize)) _ = try archive.extract(entry, bufferSize: chunkSize, skipCRC32: false, progress: nil) { data in - progress.completedUnitCount += Int64(data.count) + progress.advance(by: data.count) // Force a copy to avoid ZIPFoundation buffer reuse issues continuation.yield(Data(data)) } @@ -153,11 +153,7 @@ public final class ArchiveDataDistribution: Distribution { } // For TXT format, use the default implementation that reads from README let path = try findFile(prefix: "Read_me") ?? "README.txt" - let lines: AsyncThrowingStream = await readFile( - path: path, - withProgress: { _ in }, - returningLines: { _ in } - ) + let lines: AsyncThrowingStream = await readFile(path: path) for try await line in lines where line.starts(with: "AIS subscriber files effective date ".data(using: .isoLatin1)!) { return parseCycleFromReadme(line) @@ -172,12 +168,7 @@ public final class ArchiveDataDistribution: Distribution { return nil } - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US") - formatter.timeZone = TimeZone(identifier: "UTC") - formatter.dateFormat = "MMMM d, yyyy" - - guard let cycleDate = formatter.date(from: cycleDateString) else { + guard let cycleDate = parseReadmeCycleDate(cycleDateString) else { return nil } diff --git a/Sources/SwiftNASR/Distribution/ArchiveFileDistribution.swift b/Sources/SwiftNASR/Distribution/ArchiveFileDistribution.swift index 3a1ebd5..5453e5f 100644 --- a/Sources/SwiftNASR/Distribution/ArchiveFileDistribution.swift +++ b/Sources/SwiftNASR/Distribution/ArchiveFileDistribution.swift @@ -1,5 +1,5 @@ public import Foundation -@preconcurrency import ZIPFoundation +@unsafe @preconcurrency import ZIPFoundation /** A NASR distribution that has been loaded from a ZIP archive and saved to a @@ -46,7 +46,7 @@ public final class ArchiveFileDistribution: Distribution { @discardableResult private func readFileWithCallback( path: String, - withProgress progressHandler: (Progress) -> Void = { _ in }, + progress: ByteReadProgress, eachLine: (Data) -> Void ) throws -> UInt { // Try exact match first, then case-insensitive match @@ -57,14 +57,13 @@ public final class ArchiveFileDistribution: Distribution { var lines: UInt = 0 var totalBytesProcessed: UInt64 = 0 - let progress = Progress(totalUnitCount: Int64(entry.uncompressedSize)) - progressHandler(progress) + progress.setTotal(Int(clamping: entry.uncompressedSize)) _ = try archive.extract(entry, bufferSize: chunkSize, skipCRC32: true, progress: nil) { data in totalBytesProcessed += UInt64(data.count) buffer.append(data) - progress.completedUnitCount += Int64(data.count) + progress.advance(by: data.count) // Process lines from buffer - handle both \r\n and \n line endings while true { @@ -119,12 +118,13 @@ public final class ArchiveFileDistribution: Distribution { public func readFile( path: String, - withProgress progressHandler: (Progress) -> Void = { _ in }, + progress: consuming Subprogress? = nil, returningLines linesHandler: (UInt) -> Void = { _ in } ) -> AsyncThrowingStream { + let progress = ByteReadProgress(progress) return AsyncThrowingStream { continuation in do { - let lines = try readFileWithCallback(path: path, withProgress: progressHandler) { data in + let lines = try readFileWithCallback(path: path, progress: progress) { data in continuation.yield(data) } linesHandler(lines) @@ -137,8 +137,9 @@ public final class ArchiveFileDistribution: Distribution { public func readFileRaw( path: String, - withProgress progressHandler: (Progress) -> Void = { _ in } + progress: consuming Subprogress? = nil ) -> AsyncThrowingStream { + let progress = ByteReadProgress(progress) return AsyncThrowingStream { continuation in do { // Try exact match first, then case-insensitive match @@ -149,12 +150,11 @@ public final class ArchiveFileDistribution: Distribution { return } - let progress = Progress(totalUnitCount: Int64(entry.uncompressedSize)) - progressHandler(progress) + progress.setTotal(Int(clamping: entry.uncompressedSize)) _ = try archive.extract(entry, bufferSize: chunkSize, skipCRC32: true, progress: nil) { data in - progress.completedUnitCount += Int64(data.count) + progress.advance(by: data.count) // Force a copy to avoid ZIPFoundation buffer reuse issues continuation.yield(Data(data)) } @@ -175,11 +175,7 @@ public final class ArchiveFileDistribution: Distribution { } // For TXT format, read from the README file let path = try findFile(prefix: "Read_me") ?? "README.txt" - let lines: AsyncThrowingStream = await readFile( - path: path, - withProgress: { _ in }, - returningLines: { _ in } - ) + let lines: AsyncThrowingStream = await readFile(path: path) let readmePrefix = "AIS subscriber files effective date ".data(using: .isoLatin1)! for try await line in lines where line.starts(with: readmePrefix) { return parseCycleFromReadme(line) @@ -194,12 +190,7 @@ public final class ArchiveFileDistribution: Distribution { return nil } - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US") - formatter.timeZone = TimeZone(identifier: "UTC") - formatter.dateFormat = "MMMM d, yyyy" - - guard let cycleDate = formatter.date(from: cycleDateString) else { + guard let cycleDate = parseReadmeCycleDate(cycleDateString) else { return nil } diff --git a/Sources/SwiftNASR/Distribution/DirectoryDistribution.swift b/Sources/SwiftNASR/Distribution/DirectoryDistribution.swift index 9af2296..55c479b 100644 --- a/Sources/SwiftNASR/Distribution/DirectoryDistribution.swift +++ b/Sources/SwiftNASR/Distribution/DirectoryDistribution.swift @@ -40,7 +40,7 @@ public final class DirectoryDistribution: Distribution { @discardableResult private func readFileWithCallback( path: String, - withProgress progressHandler: (Progress) -> Void = { _ in }, + progress: ByteReadProgress, eachLine: (Data) -> Void ) throws -> UInt { let fileURL = location.appendingPathComponent(path) @@ -60,8 +60,7 @@ public final class DirectoryDistribution: Distribution { try FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as! NSNumber var lines: UInt = 0 - let progress = Progress(totalUnitCount: filesize.int64Value) - progressHandler(progress) + progress.setTotal(Int(clamping: filesize.int64Value)) while true { if let EOL = buffer.range(of: delimiter) { @@ -72,12 +71,10 @@ public final class DirectoryDistribution: Distribution { let subrange = buffer.startIndex.. Void = { _ in }, + progress: consuming Subprogress? = nil, returningLines linesHandler: (UInt) -> Void = { _ in } ) -> AsyncThrowingStream { + let progress = ByteReadProgress(progress) return AsyncThrowingStream { continuation in do { - let lines = try readFileWithCallback(path: path, withProgress: progressHandler) { data in + let lines = try readFileWithCallback(path: path, progress: progress) { data in continuation.yield(data) } linesHandler(lines) @@ -124,8 +122,9 @@ public final class DirectoryDistribution: Distribution { public func readFileRaw( path: String, - withProgress progressHandler: (Progress) -> Void = { _ in } + progress: consuming Subprogress? = nil ) -> AsyncThrowingStream { + let progress = ByteReadProgress(progress) return AsyncThrowingStream { continuation in do { let fileURL = location.appendingPathComponent(path) @@ -143,13 +142,12 @@ public final class DirectoryDistribution: Distribution { let filesize = try FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as! NSNumber - let progress = Progress(totalUnitCount: filesize.int64Value) - progressHandler(progress) + progress.setTotal(Int(clamping: filesize.int64Value)) while true { let data = handle.readData(ofLength: chunkSize) guard !data.isEmpty else { break } - progress.completedUnitCount += Int64(data.count) + progress.advance(by: data.count) continuation.yield(data) } @@ -186,12 +184,7 @@ public final class DirectoryDistribution: Distribution { let dateString = String(line.dropFirst("AIS subscriber files effective date ".count)) .trimmingCharacters(in: .whitespaces) - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US") - formatter.timeZone = TimeZone(secondsFromGMT: 0)! - formatter.dateFormat = "MMMM d, yyyy" - - if let date = formatter.date(from: dateString) { + if let date = parseReadmeCycleDate(dateString) { let components = Calendar(identifier: .gregorian).dateComponents( in: TimeZone(secondsFromGMT: 0)!, from: date diff --git a/Sources/SwiftNASR/Distribution/Distribution+Cycle.swift b/Sources/SwiftNASR/Distribution/Distribution+Cycle.swift index 025c76a..df73c9c 100644 --- a/Sources/SwiftNASR/Distribution/Distribution+Cycle.swift +++ b/Sources/SwiftNASR/Distribution/Distribution+Cycle.swift @@ -1,14 +1,42 @@ import Foundation -extension Distribution { - private var cycleDateFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US") - formatter.timeZone = zulu - formatter.dateFormat = "MMMM d, yyyy" - return formatter - } +/// Parses the effective date as the distribution README spells it (e.g. `October 30, 2025`). +let readmeCycleDateStrategy = Date.ParseStrategy( + format: "\(month: .wide) \(day: .defaultDigits), \(year: .defaultDigits)", + locale: Locale(identifier: "en_US"), + timeZone: zulu, + calendar: Calendar(identifier: .gregorian) +) + +/// Renders a cycle date back into the README's spelling, for validating a parse. +private var readmeCycleDateStyle: Date.FormatStyle { + Date.FormatStyle( + date: .long, + locale: Locale(identifier: "en_US"), + calendar: Calendar(identifier: .gregorian), + timeZone: zulu + ) + .month(.wide) + .day(.defaultDigits) + .year(.defaultDigits) +} +/// Parses a README effective date, rejecting anything the README would not have written. +/// +/// `Date.ParseStrategy` is lenient in two ways this format cannot tolerate: it rolls out-of-range +/// components over, reading `October 32, 2025` as November 1st, and it stops at the first match, +/// accepting trailing text. Both would yield a plausible but wrong ``Cycle``, which callers turn +/// straight into a download URL. Re-rendering the result and requiring it to equal the input +/// rejects both, while still tolerating surrounding whitespace. +func parseReadmeCycleDate(_ string: String) -> Date? { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard let date = try? readmeCycleDateStrategy.parse(trimmed), + readmeCycleDateStyle.format(date) == trimmed + else { return nil } + return date +} + +extension Distribution { private var readmeFirstLine: Data { "AIS subscriber files effective date ".data(using: .isoLatin1)! } @@ -33,7 +61,7 @@ extension Distribution { let lines: AsyncThrowingStream = await readFile( path: path, - withProgress: { _ in }, + progress: nil, returningLines: { _ in } ) @@ -48,7 +76,7 @@ extension Distribution { guard let cycleDateString = String(data: cycleDateData, encoding: .isoLatin1) else { return nil } - guard let cycleDate = cycleDateFormatter.date(from: cycleDateString) else { + guard let cycleDate = parseReadmeCycleDate(cycleDateString) else { return nil } diff --git a/Sources/SwiftNASR/Distribution/Distribution.swift b/Sources/SwiftNASR/Distribution/Distribution.swift index 48a69cf..ad24260 100644 --- a/Sources/SwiftNASR/Distribution/Distribution.swift +++ b/Sources/SwiftNASR/Distribution/Distribution.swift @@ -46,7 +46,7 @@ public protocol Distribution: Sendable { /// The data format (TXT or CSV) for this distribution. /// /// This property determines which parsers are used when calling - /// ``NASR/parse(_:withProgress:errorHandler:)``. TXT format uses fixed-width + /// ``NASR/parse(_:progress:errorHandler:)``. TXT format uses fixed-width /// parsers, while CSV format uses comma-separated value parsers. var format: DataFormat { get } @@ -64,9 +64,8 @@ public protocol Distribution: Sendable { Decompresses and reads a file asynchronously from a distribution. - Parameter path: The path to the file. - - Parameter progressHandler: A block that receives the Progress object when - the task begins. You can add it to your parent - Progress. + - Parameter progress: A subprogress, obtained from your own `ProgressManager`, that reports + how much of the file has been read. Pass `nil` to track no progress. - Parameter linesHandler: Called when the number of lines in the file is known. - Returns: An `AsyncStream` that contains each line, in order, from the @@ -76,7 +75,7 @@ public protocol Distribution: Sendable { @FileReadActor func readFile( path: String, - withProgress progressHandler: @Sendable (_ progress: Progress) -> Void, + progress: consuming Subprogress?, returningLines linesHandler: (_ lines: UInt) -> Void ) -> AsyncThrowingStream @@ -95,14 +94,14 @@ public protocol Distribution: Sendable { parsers with multi-line quoted fields). - Parameter path: The path to the file within the distribution. - - Parameter progressHandler: A block that receives the Progress object when - the task begins. + - Parameter progress: A subprogress, obtained from your own `ProgressManager`, that reports + how much of the file has been read. Pass `nil` to track no progress. - Returns: An `AsyncThrowingStream` that yields raw data chunks. */ @FileReadActor func readFileRaw( path: String, - withProgress progressHandler: @Sendable (_ progress: Progress) -> Void + progress: consuming Subprogress? ) -> AsyncThrowingStream } @@ -112,30 +111,30 @@ extension Distribution { Reads the data for a given record type from the distribution. - Parameter type: The record type to read data for. - - Parameter progressHandler: A block that receives the Progress object when - the task begins. You can add it to your parent Progress. + - Parameter progress: A subprogress, obtained from your own `ProgressManager`, that reports + how much of the record file has been read. Pass `nil` to track no + progress. - Parameter linesHandler: Called when the number of lines in the file is known. - - Returns: An async stream of data from the record file, and the progress - through that file. + - Returns: An async stream of data from the record file. */ @FileReadActor public func read( type: RecordType, - withProgress progressHandler: @Sendable (_ progress: Progress) -> Void = { _ in }, + progress: consuming Subprogress? = nil, returningLines linesHandler: (_ lines: UInt) -> Void = { _ in } ) -> AsyncThrowingStream { switch format { case .txt: return readFile( path: "\(type.rawValue).txt", - withProgress: progressHandler, + progress: progress, returningLines: linesHandler ) case .csv: // For CSV format, we need to handle multiple files per record type - return readCSVFiles(for: type, withProgress: progressHandler, returningLines: linesHandler) + return readCSVFiles(for: type, progress: progress, returningLines: linesHandler) } } @@ -143,7 +142,7 @@ extension Distribution { @FileReadActor public func readCSVFiles( for type: RecordType, - withProgress progressHandler: @Sendable (_ progress: Progress) -> Void = { _ in }, + progress: consuming Subprogress? = nil, returningLines linesHandler: (_ lines: UInt) -> Void = { _ in } ) -> AsyncThrowingStream { // For CSV format, the actual parsing happens in the CSV parsers which read files directly @@ -165,9 +164,7 @@ extension Distribution { // For CSV, we'll just return a marker indicating CSV format // The actual parsing happens in the CSV parsers which read files directly // Report a completed progress for the "read" phase since CSV files are read all at once - let readProgress = Progress(totalUnitCount: 1) - readProgress.completedUnitCount = 1 - progressHandler(readProgress) + completeImmediately(progress) // Call the lines handler with 1 to ensure parseProgress is initialized linesHandler(1) diff --git a/Sources/SwiftNASR/Distribution/NullDistribution.swift b/Sources/SwiftNASR/Distribution/NullDistribution.swift index 2f09cfa..92bc793 100644 --- a/Sources/SwiftNASR/Distribution/NullDistribution.swift +++ b/Sources/SwiftNASR/Distribution/NullDistribution.swift @@ -16,7 +16,7 @@ public final class NullDistribution: Distribution { public func readFile( path _: String, - withProgress _: @Sendable (Progress) -> Void = { _ in }, + progress _: consuming Subprogress? = nil, returningLines _: (UInt) -> Void = { _ in } ) -> AsyncThrowingStream { return AsyncThrowingStream { $0.finish(throwing: Error.nullDistribution) } @@ -24,7 +24,7 @@ public final class NullDistribution: Distribution { public func readFileRaw( path _: String, - withProgress _: @Sendable (Progress) -> Void = { _ in } + progress _: consuming Subprogress? = nil ) -> AsyncThrowingStream { return AsyncThrowingStream { $0.finish(throwing: Error.nullDistribution) } } diff --git a/Sources/SwiftNASR/Documentation.docc/Distribution.md b/Sources/SwiftNASR/Documentation.docc/Distribution.md index 7f373bc..c1f2d67 100644 --- a/Sources/SwiftNASR/Documentation.docc/Distribution.md +++ b/Sources/SwiftNASR/Documentation.docc/Distribution.md @@ -49,14 +49,14 @@ information. ### Reading a File -- ``readFile(path:withProgress:returningLines:)`` +- ``readFile(path:progress:returningLines:)`` ### Reading Records These methods are already implemented and normally do not need to be overridden. - ``RecordType`` -- ``read(type:withProgress:returningLines:)`` +- ``read(type:progress:returningLines:)`` ### Reading the Cycle diff --git a/Sources/SwiftNASR/Documentation.docc/Downloader.md b/Sources/SwiftNASR/Documentation.docc/Downloader.md index 4752731..3ff22c0 100644 --- a/Sources/SwiftNASR/Documentation.docc/Downloader.md +++ b/Sources/SwiftNASR/Documentation.docc/Downloader.md @@ -32,4 +32,4 @@ and a subclass of ``Distribution`` is generated to process that data. ### Downloading Distributions -- ``load(withProgress:)`` +- ``load(progress:)`` diff --git a/Sources/SwiftNASR/Documentation.docc/Getting Started.md b/Sources/SwiftNASR/Documentation.docc/Getting Started.md index 203efdd..0103b22 100644 --- a/Sources/SwiftNASR/Documentation.docc/Getting Started.md +++ b/Sources/SwiftNASR/Documentation.docc/Getting Started.md @@ -66,7 +66,7 @@ print(sanCarlos.runways[0].length) `data` is an object of type ``NASRData``. Its fields, such as ``NASRData/airports`` will only not be `nil` once -``NASR/parse(_:withProgress:errorHandler:)`` has been called for that data type +``NASR/parse(_:progress:errorHandler:)`` has been called for that data type (as in the example above). To avoid parsing a large dataset each time your application loads, I recommend diff --git a/Sources/SwiftNASR/Documentation.docc/Loader.md b/Sources/SwiftNASR/Documentation.docc/Loader.md index ec373e3..ac7b1fa 100644 --- a/Sources/SwiftNASR/Documentation.docc/Loader.md +++ b/Sources/SwiftNASR/Documentation.docc/Loader.md @@ -27,4 +27,4 @@ only retrieves it from disk, memory, or the Internet. ### Loading Data -- ``load(withProgress:)`` +- ``load(progress:)`` diff --git a/Sources/SwiftNASR/Documentation.docc/Records.md b/Sources/SwiftNASR/Documentation.docc/Records.md index 6925662..0939c89 100644 --- a/Sources/SwiftNASR/Documentation.docc/Records.md +++ b/Sources/SwiftNASR/Documentation.docc/Records.md @@ -5,7 +5,7 @@ After one of the `load` methods is called (see _Loaders_), records can be parsed. Records are stored in a ``NASRData`` instance after being loaded by the -``NASR/parse(_:withProgress:errorHandler:)`` method. +``NASR/parse(_:progress:errorHandler:)`` method. Each record maintains an internal link back to its parent ``NASRData`` object. This is used to allow records to cross-reference each other; e.g., the @@ -63,7 +63,7 @@ data.airports[0].tieInFSSID // returns a String identifier - ``NASR`` - ``NASRData`` -- ``NASR/parse(_:withProgress:errorHandler:)`` +- ``NASR/parse(_:progress:errorHandler:)`` ### Associated Types diff --git a/Sources/SwiftNASR/Downloaders/ArchiveDataDownloader.swift b/Sources/SwiftNASR/Downloaders/ArchiveDataDownloader.swift index 330bf85..09d974a 100644 --- a/Sources/SwiftNASR/Downloaders/ArchiveDataDownloader.swift +++ b/Sources/SwiftNASR/Downloaders/ArchiveDataDownloader.swift @@ -27,11 +27,8 @@ public final class ArchiveDataDownloader: Downloader { self.session = session } - public func load(withProgress progressHandler: @Sendable (Progress) -> Void = { _ in }) - async throws -> any Distribution - { - let delegate = DownloadDelegate() - progressHandler(delegate.progress) + public func load(progress: consuming Subprogress? = nil) async throws -> any Distribution { + let delegate = DownloadDelegate(progress: progress) let (data, response) = try await session.data(from: cycleURL, delegate: delegate) diff --git a/Sources/SwiftNASR/Downloaders/ArchiveFileDownloader.swift b/Sources/SwiftNASR/Downloaders/ArchiveFileDownloader.swift index d273cbb..35abb08 100644 --- a/Sources/SwiftNASR/Downloaders/ArchiveFileDownloader.swift +++ b/Sources/SwiftNASR/Downloaders/ArchiveFileDownloader.swift @@ -37,15 +37,13 @@ public final class ArchiveFileDownloader: Downloader { self.session = session } - public func load(withProgress progressHandler: @Sendable (Progress) -> Void = { _ in }) - async throws -> any Distribution - { - let delegate = DownloadDelegate() - progressHandler(delegate.progress) + public func load(progress: consuming Subprogress? = nil) async throws -> any Distribution { + let delegate = DownloadDelegate(progress: progress) let (tempfileURL, response) = try await session.download(from: cycleURL, delegate: delegate) guard let HTTPResponse = response as? HTTPURLResponse else { + try? FileManager.default.removeItem(at: tempfileURL) throw Error.badResponse(response) } diff --git a/Sources/SwiftNASR/Downloaders/Downloader.swift b/Sources/SwiftNASR/Downloaders/Downloader.swift index 0792fe8..2b77806 100644 --- a/Sources/SwiftNASR/Downloaders/Downloader.swift +++ b/Sources/SwiftNASR/Downloaders/Downloader.swift @@ -33,19 +33,25 @@ public protocol Downloader: Loader { /** Downloads the NASR data asynchronously. - - Returns: The downloaded distribution, and an object for tracking progress. + - Parameter progress: A subprogress, obtained from your own `ProgressManager`, that reports + download progress. Pass `nil` to track no progress. + - Returns: The downloaded distribution. - Throws: If the distribution could not be downloaded. */ - func load(withProgress progressHandler: @Sendable (Progress) -> Void) async throws - -> any Distribution + func load(progress: consuming Subprogress?) async throws -> any Distribution } #if canImport(Darwin) @objc #endif final class DownloadDelegate: NSObject, URLSessionDownloadDelegate, Sendable { - let progress = Progress(totalUnitCount: 0) + private let progress: ByteReadProgress + + init(progress: consuming Subprogress?) { + self.progress = ByteReadProgress(progress) + super.init() + } func urlSession( _: URLSession, @@ -62,47 +68,50 @@ final class DownloadDelegate: NSObject, URLSessionDownloadDelegate, Sendable { totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64 ) { - progress.completedUnitCount = totalBytesWritten - progress.totalUnitCount = totalBytesExpectedToWrite + progress.update( + completed: Int(clamping: totalBytesWritten), + total: Int(clamping: totalBytesExpectedToWrite) + ) } } -private var cycleDateFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd" - formatter.timeZone = zulu - return formatter -} +/// Formats the cycle effective date the way the TXT distribution URL spells it (e.g. `2025-09-04`). +private let TXTCycleDateStyle = Date.VerbatimFormatStyle( + format: "\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits)", + locale: Locale(identifier: "en_US_POSIX"), + timeZone: zulu, + calendar: Calendar(identifier: .gregorian) +) + +/// Formats the cycle effective date the way the CSV distribution URL spells it (e.g. `04_Sep_2025`). +private let CSVCycleDateStyle = Date.VerbatimFormatStyle( + format: "\(day: .twoDigits)_\(month: .abbreviated)_\(year: .defaultDigits)", + locale: Locale(identifier: "en_US"), + timeZone: zulu, + calendar: Calendar(identifier: .gregorian) +) // swiftlint:disable missing_docs extension Downloader { public var cycleURL: URL { switch format { case .txt: - let cycleString = cycleDateFormatter.string(from: cycle.effectiveDate!) + let cycleString = TXTCycleDateStyle.format(cycle.effectiveDate!) return URL( string: "https://nfdc.faa.gov/webContent/28DaySub/28DaySubscription_Effective_\(cycleString).zip" )! case .csv: - // CSV format uses DD_Mmm_YYYY format (e.g., 04_Sep_2025_CSV.zip) - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US") - formatter.timeZone = TimeZone(identifier: "UTC") - formatter.dateFormat = "dd_MMM_yyyy" - let csvDateString = formatter.string(from: cycle.effectiveDate!) + let cycleString = CSVCycleDateStyle.format(cycle.effectiveDate!) return URL( - string: "https://nfdc.faa.gov/webContent/28DaySub/extra/\(csvDateString)_CSV.zip" + string: "https://nfdc.faa.gov/webContent/28DaySub/extra/\(cycleString)_CSV.zip" )! } } // periphery:ignore - default protocol implementation; always provided by conformers - func load(withProgress progressHandler: @Sendable (Progress) -> Void = { _ in }) throws - -> any Distribution - { - progressHandler(completedProgress()) + func load(progress: consuming Subprogress? = nil) throws -> any Distribution { + completeImmediately(progress) return NullDistribution() } } diff --git a/Sources/SwiftNASR/Error.swift b/Sources/SwiftNASR/Error.swift index 04d760c..e78700a 100644 --- a/Sources/SwiftNASR/Error.swift +++ b/Sources/SwiftNASR/Error.swift @@ -5,7 +5,7 @@ public import Foundation /// Errors that can occur in SwiftNASR methods. public enum Error: Swift.Error, Sendable { - /// Tried to call ``NASR/load(withProgress:)`` on a ``NASR`` instance with + /// Tried to call ``NASR/load(progress:)`` on a ``NASR`` instance with /// a ``NullDistribution``. case nullDistribution @@ -32,8 +32,8 @@ public enum Error: Swift.Error, Sendable { /// Response did not contain any body. case noData - /// ``NASR/parse(_:withProgress:errorHandler:)`` was called before - /// ``NASR/load(withProgress:)``. + /// ``NASR/parse(_:progress:errorHandler:)`` was called before + /// ``NASR/load(progress:)``. case notYetLoaded /** diff --git a/Sources/SwiftNASR/Loaders/ArchiveLoader.swift b/Sources/SwiftNASR/Loaders/ArchiveLoader.swift index 3184314..94dfbc7 100644 --- a/Sources/SwiftNASR/Loaders/ArchiveLoader.swift +++ b/Sources/SwiftNASR/Loaders/ArchiveLoader.swift @@ -24,10 +24,8 @@ public final class ArchiveLoader: Loader { self.format = format } - public func load(withProgress progressHandler: @Sendable (Progress) -> Void = { _ in }) throws - -> any Distribution - { - progressHandler(completedProgress()) + public func load(progress: consuming Subprogress? = nil) throws -> any Distribution { + completeImmediately(progress) return try ArchiveFileDistribution(location: location, format: format) } } diff --git a/Sources/SwiftNASR/Loaders/DirectoryLoader.swift b/Sources/SwiftNASR/Loaders/DirectoryLoader.swift index e9733d2..272f401 100644 --- a/Sources/SwiftNASR/Loaders/DirectoryLoader.swift +++ b/Sources/SwiftNASR/Loaders/DirectoryLoader.swift @@ -24,10 +24,8 @@ public final class DirectoryLoader: Loader { self.format = format } - public func load(withProgress progressHandler: @Sendable (Progress) -> Void = { _ in }) - throws -> any Distribution - { - progressHandler(completedProgress()) + public func load(progress: consuming Subprogress? = nil) throws -> any Distribution { + completeImmediately(progress) return DirectoryDistribution(location: location, format: format) } } diff --git a/Sources/SwiftNASR/Loaders/Loader.swift b/Sources/SwiftNASR/Loaders/Loader.swift index ed5a4fa..4291d8e 100644 --- a/Sources/SwiftNASR/Loaders/Loader.swift +++ b/Sources/SwiftNASR/Loaders/Loader.swift @@ -1,11 +1,5 @@ public import Foundation -func completedProgress() -> Progress { - let progress = Progress(totalUnitCount: 1) - progress.completedUnitCount = 1 - return progress -} - /** Loaders create a ``Distribution`` from a NASR archive, on disk, in memory, or downloaded from the Internet (see ``Downloader``). For example, @@ -20,14 +14,10 @@ public protocol Loader: Sendable { Asynchronously wraps downloaded data (or data loaded from disk or memory) in an appropriate ``Distribution`` implementation. - - Parameter progressHandler: This block is called before processing begins - with a Progress object you can use to track - loading progress. You would add this object to - your parent Progress object. - - Returns: The distribution data wrapped in the appropriate implementation, - and an object you can use to track progress. + - Parameter progress: A subprogress, obtained from your own `ProgressManager`, that reports + loading progress. Pass `nil` to track no progress. + - Returns: The distribution data wrapped in the appropriate implementation. */ - func load(withProgress progressHandler: @Sendable (Progress) -> Void) async throws - -> any Distribution + func load(progress: consuming Subprogress?) async throws -> any Distribution } diff --git a/Sources/SwiftNASR/Loaders/NullLoader.swift b/Sources/SwiftNASR/Loaders/NullLoader.swift index 950bf74..52b5a65 100644 --- a/Sources/SwiftNASR/Loaders/NullLoader.swift +++ b/Sources/SwiftNASR/Loaders/NullLoader.swift @@ -52,10 +52,8 @@ public final class NullLoader: Loader { Yields a ``NullDistribution`` that cannot be used to parse NASR data. */ - public func load(withProgress progressHandler: @Sendable (Progress) -> Void = { _ in }) throws - -> any Distribution - { - progressHandler(completedProgress()) + public func load(progress: consuming Subprogress? = nil) throws -> any Distribution { + completeImmediately(progress) return NullDistribution() } } diff --git a/Sources/SwiftNASR/Models/Records/FSS.swift b/Sources/SwiftNASR/Models/Records/FSS.swift index 000a1b8..305ff18 100644 --- a/Sources/SwiftNASR/Models/Records/FSS.swift +++ b/Sources/SwiftNASR/Models/Records/FSS.swift @@ -7,7 +7,7 @@ public import Foundation Fields in this model that reference other record types (e.g., ``airport``, which references ``Airport``) will be `nil` unless the - associated type has been parsed with ``NASR/parse(_:withProgress:errorHandler:)``. + associated type has been parsed with ``NASR/parse(_:progress:errorHandler:)``. */ public struct FSS: ParentRecord { diff --git a/Sources/SwiftNASR/Models/Records/Navaid.swift b/Sources/SwiftNASR/Models/Records/Navaid.swift index 09382d4..0c0d7fd 100644 --- a/Sources/SwiftNASR/Models/Records/Navaid.swift +++ b/Sources/SwiftNASR/Models/Records/Navaid.swift @@ -8,7 +8,7 @@ public import Foundation /// /// Fields in this model that reference other record types (e.g., /// ``lowAltitudeARTCC``, which references ``ARTCC``) will be `nil` unless the -/// associated type has been parsed with ``NASR/parse(_:withProgress:errorHandler:)`. +/// associated type has been parsed with ``NASR/parse(_:progress:errorHandler:)`. public struct Navaid: ParentRecord { /// The FAA identifier for this navaid (e.g., "OAK"). diff --git a/Sources/SwiftNASR/NASR.swift b/Sources/SwiftNASR/NASR.swift index ea0d2ef..a76701c 100644 --- a/Sources/SwiftNASR/NASR.swift +++ b/Sources/SwiftNASR/NASR.swift @@ -23,7 +23,7 @@ public actor NASR { var distribution: (any Distribution)? /// Aeronautical data is stored into this field once it is parsed. All members - /// of this instance are `nil` until the ``parse(_:withProgress:errorHandler:)`` + /// of this instance are `nil` until the ``parse(_:progress:errorHandler:)`` /// function is called for each data type. The ``NASRData`` object can be /// serialized to disk using an `Encoder`. public var data = NASRData() @@ -144,16 +144,12 @@ public actor NASR { /** Asynchronously loads data, either from disk or from the Internet. - - Parameter progressHandler: This block is called before processing begins - with a Progress object that you can use to - track loading progress. You would add this - object to your parent Progress object. + - Parameter progress: A subprogress, obtained from your own `ProgressManager`, that reports + loading progress. Pass `nil` to track no progress. */ - public func load(withProgress progressHandler: @Sendable (Progress) -> Void = { _ in }) - async throws - { - distribution = try await loader.load(withProgress: progressHandler) + public func load(progress: consuming Subprogress? = nil) async throws { + distribution = try await loader.load(progress: progress) let cycle = try await distribution!.readCycle() await data.finishParsing(cycle: cycle) } @@ -163,10 +159,8 @@ public actor NASR { Populates the corresponding field in the ``NASRData`` field of ``data``. - Parameter type: The type of data to parse. - - Parameter progressHandler: This block is called before processing begins - with a Progress object that you can use to - track loading progress. You would add this - object to your parent Progress object. + - Parameter progress: A subprogress, obtained from your own `ProgressManager`, that reports + parsing progress. Pass `nil` to track no progress. - Parameter errorHandler: Called whenever a parsing problem is encountered. Receives a ``RecordParseError`` describing the problem, and returns a ``ParseDisposition``: @@ -178,7 +172,7 @@ public actor NASR { @discardableResult public func parse( _ type: RecordType, - withProgress progressHandler: @Sendable (Progress) -> Void = { _ in }, + progress: consuming Subprogress? = nil, errorHandler: @Sendable (_ error: RecordParseError) -> ParseDisposition ) async throws -> Bool { guard let distribution = self.distribution else { throw Error.notYetLoaded } @@ -201,9 +195,8 @@ public actor NASR { _ = await diagnosing.takeDiagnostics() } - if let csvParser = parser as? (any CSVParser) { - let progress = await csvParser.setupProgress() - progressHandler(progress) + if parser is any CSVParser { + let readProgress = progress?.start(totalCount: 1) let data = await distribution.read(type: type) for try await chunk in data { @@ -223,51 +216,42 @@ public actor NASR { } } - progress.completedUnitCount = progress.totalUnitCount + readProgress?.complete(count: 1) await parser.finish(data: self.data) return true } - let progress = Progress(totalUnitCount: 10) - progressHandler(progress) - var parseProgress: Progress! + // The fixed-width path reads the record file, then parses it line by line: one tenth of the + // work is the read, the rest the parse. + let recordProgress = progress?.start(totalCount: 10) + var parseProgress: ProgressManager? let data = switch type { case .states: await distribution.readFile( path: "State_&_Country_Codes/STATE.txt", - withProgress: { readProgress in - progress.addChild(readProgress, withPendingUnitCount: 1) - }, + progress: recordProgress?.subprogress(assigningCount: 1), returningLines: { lines in - parseProgress = Progress( - totalUnitCount: Int64(lines), - parent: progress, - pendingUnitCount: 9 - ) + parseProgress = recordProgress?.subprogress(assigningCount: 9) + .start(totalCount: Int(lines)) } ) default: await distribution.read( type: type, - withProgress: { readProgress in - progress.addChild(readProgress, withPendingUnitCount: 1) - }, + progress: recordProgress?.subprogress(assigningCount: 1), returningLines: { lines in - parseProgress = Progress( - totalUnitCount: Int64(lines), - parent: progress, - pendingUnitCount: 9 - ) + parseProgress = recordProgress?.subprogress(assigningCount: 9) + .start(totalCount: Int(lines)) } ) } for try await chunk in data { + defer { parseProgress?.complete(count: 1) } do { try await parser.parse(data: chunk) - parseProgress.completedUnitCount += 1 } catch { await discardPendingDiagnostics() if errorHandler(.fromThrown(recordType: type, recordID: nil, error)) == .abort { diff --git a/Sources/SwiftNASR/NASRData.swift b/Sources/SwiftNASR/NASRData.swift index 65e21a5..fabc5d6 100644 --- a/Sources/SwiftNASR/NASRData.swift +++ b/Sources/SwiftNASR/NASRData.swift @@ -3,7 +3,7 @@ import Foundation /** A data class containing all data parsed from a NASR distribution. The members of this class will be `nil` until their respective calls to - ``NASR/parse(_:withProgress:errorHandler:)`` have been made. + ``NASR/parse(_:progress:errorHandler:)`` have been made. This class can be encoded to disk using ``NASRDataCodable``, and re-associated with a SwiftNASR object using ``NASR/fromData(_:)``. Parsing the data takes diff --git a/Sources/SwiftNASR/Parsers/ByteParsing/ByteTransformer.swift b/Sources/SwiftNASR/Parsers/ByteParsing/ByteTransformer.swift index ad71401..512afdf 100644 --- a/Sources/SwiftNASR/Parsers/ByteParsing/ByteTransformer.swift +++ b/Sources/SwiftNASR/Parsers/ByteParsing/ByteTransformer.swift @@ -61,10 +61,10 @@ struct ByteTransformer { return try transformFrequency(slice, nullable: nullable, index: index) case let .boolean(trueValue, nullable): return try transformBoolean(slice, trueValue: trueValue, nullable: nullable, index: index) - case let .datetime(formatter, nullable): + case let .datetime(strategy, nullable): return try transformDatetime( slice, - formatter: formatter, + strategy: strategy, nullable: nullable, index: index ) @@ -210,13 +210,13 @@ struct ByteTransformer { private func transformDatetime( _ slice: ByteSlice, - formatter: DateFormatter, + strategy: Date.ParseStrategy, nullable: Nullable, index: Int ) throws -> Any? { try transform(slice, nullable: nullable, index: index, trim: true) { bytes in guard let str = bytes.toString(), - let transformed = formatter.date(from: str) + let transformed = try? strategy.parse(str) else { let str = bytes.toString() ?? "" throw FixedWidthParserError.invalidDate(str, at: index) diff --git a/Sources/SwiftNASR/Parsers/CSV/CSVParser.swift b/Sources/SwiftNASR/Parsers/CSV/CSVParser.swift index 4f5afd4..c3fe836 100644 --- a/Sources/SwiftNASR/Parsers/CSV/CSVParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/CSVParser.swift @@ -1,4 +1,4 @@ -public import Foundation +internal import Foundation import StreamingCSV /** @@ -14,23 +14,9 @@ public protocol CSVParser: Parser { /// The CSV files this parser will process. var CSVFiles: [String] { get } - - /// Progress object for reporting parsing progress. - var progress: Progress? { get set } - - /// Cumulative bytes read across all files (for progress tracking). - var bytesRead: Int64 { get set } } extension CSVParser { - /// Sets up progress tracking and returns the Progress object. - func setupProgress() -> Progress { - let prog = Progress(totalUnitCount: 1) - progress = prog - bytesRead = 0 - return prog - } - /// Parse a CSV file with header-based row access. /// /// This method reads the header row to build a column name to index mapping, @@ -50,7 +36,7 @@ extension CSVParser { throw ParserError.badData("Distribution not set for CSV parser") } - let dataStream = await distribution.readFileRaw(path: filename) { _ in } + let dataStream = await distribution.readFileRaw(path: filename, progress: nil) // FAA data files use Latin-1 (ISO-8859-1) encoding for special characters like degree symbols let rowStream = StreamingCSVReader.stream(from: dataStream, encoding: .isoLatin1) diff --git a/Sources/SwiftNASR/Parsers/CSV/CSVTransformer.swift b/Sources/SwiftNASR/Parsers/CSV/CSVTransformer.swift index 807c48f..afc5519 100644 --- a/Sources/SwiftNASR/Parsers/CSV/CSVTransformer.swift +++ b/Sources/SwiftNASR/Parsers/CSV/CSVTransformer.swift @@ -197,10 +197,10 @@ struct CSVTransformer { str in return str == trueValue } - case let .datetime(formatter, nullable): + case let .datetime(strategy, nullable): return try transformColumn(value, nullable: nullable, column: columnName, trim: true) { str in - guard let transformed = formatter.date(from: str) else { + guard let transformed = try? strategy.parse(str) else { throw CSVParserError.invalidDateInColumn(str, column: columnName) } return transformed diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVARTCCBoundarySegmentParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVARTCCBoundarySegmentParser.swift index c68bcc3..389c429 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVARTCCBoundarySegmentParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVARTCCBoundarySegmentParser.swift @@ -8,8 +8,6 @@ import StreamingCSV actor CSVARTCCBoundarySegmentParser: CSVParser, DiagnosingParser { static let type = RecordType.ARTCCBoundarySegments var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["ARB_SEG.csv"] var pendingDiagnostics = [RecordParseError]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVARTCCParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVARTCCParser.swift index 21048a5..eab90a9 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVARTCCParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVARTCCParser.swift @@ -6,8 +6,6 @@ actor CSVARTCCParser: CSVParser, DiagnosingParser { static let type = RecordType.ARTCCFacilities var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["ATC_BASE.csv", "ATC_RMK.csv", "ATC_SVC.csv"] var ARTCCs = [ARTCCKey: ARTCC]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVAirportParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVAirportParser.swift index 71dd71a..6055894 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVAirportParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVAirportParser.swift @@ -6,8 +6,6 @@ actor CSVAirportParser: CSVParser, DiagnosingParser { static let type = RecordType.airports var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = [ "FRQ.csv", "APT_BASE.csv", "APT_CON.csv", "APT_RWY.csv", "APT_RWY_END.csv", "APT_ARS.csv", "APT_ATT.csv", "APT_RMK.csv" diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVAirwayParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVAirwayParser.swift index 5ba0d62..2189d7f 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVAirwayParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVAirwayParser.swift @@ -6,8 +6,6 @@ actor CSVAirwayParser: CSVParser, DiagnosingParser { static let type = RecordType.airways var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["AWY_BASE.csv", "AWY_SEG_ALT.csv"] var airways = [AirwayKey: Airway]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVCodedDepartureRouteParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVCodedDepartureRouteParser.swift index fb91a78..a4af24f 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVCodedDepartureRouteParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVCodedDepartureRouteParser.swift @@ -7,8 +7,6 @@ import StreamingCSV /// RCode, Orig, Dest, DepFix, Route String, DCNTR, ACNTR, TCNTRs, CoordReq, Play, NavEqp, Length actor CSVCodedDepartureRouteParser: CSVParser { var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["CDR.csv"] var routes = [String: CodedDepartureRoute]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVDepartureArrivalProcedureParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVDepartureArrivalProcedureParser.swift index 26ae8ee..86008c9 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVDepartureArrivalProcedureParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVDepartureArrivalProcedureParser.swift @@ -6,8 +6,6 @@ import StreamingCSV /// Parses DP_BASE.csv, DP_APT.csv, DP_RTE.csv, STAR_BASE.csv, STAR_APT.csv, and STAR_RTE.csv. actor CSVDepartureArrivalProcedureParser: CSVParser { var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = [ "DP_BASE.csv", "DP_APT.csv", "DP_RTE.csv", "STAR_BASE.csv", "STAR_APT.csv", "STAR_RTE.csv" @@ -37,7 +35,7 @@ actor CSVDepartureArrivalProcedureParser: CSVParser { // Generate a unique sequence number for this procedure self.sequenceCounter += 1 - let sequenceNumber = String(format: "%05d", self.sequenceCounter) + let sequenceNumber = unsafe String(format: "%05d", self.sequenceCounter) let procedure = DepartureArrivalProcedure( procedureType: .DP, @@ -62,7 +60,7 @@ actor CSVDepartureArrivalProcedureParser: CSVParser { // Generate a unique sequence number for this procedure self.sequenceCounter += 1 - let sequenceNumber = String(format: "%05d", self.sequenceCounter) + let sequenceNumber = unsafe String(format: "%05d", self.sequenceCounter) let procedure = DepartureArrivalProcedure( procedureType: .STAR, diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVFSSCommFacilityParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVFSSCommFacilityParser.swift index db0edca..b7d96f0 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVFSSCommFacilityParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVFSSCommFacilityParser.swift @@ -9,8 +9,6 @@ import StreamingCSV actor CSVFSSCommFacilityParser: CSVParser, DiagnosingParser { static let type = RecordType.FSSCommFacilities var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["COM.csv"] var facilities = [FSSCommFacility]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVFSSParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVFSSParser.swift index 3ae9e49..17cb26b 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVFSSParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVFSSParser.swift @@ -4,8 +4,6 @@ import StreamingCSV /// CSV FSS Parser using declarative transformers like FixedWidthFSSParser actor CSVFSSParser: CSVParser { var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["FSS_BASE.csv", "FSS_RMK.csv"] var FSSes = [String: FSS]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVFixParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVFixParser.swift index 10ab4ad..d175d66 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVFixParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVFixParser.swift @@ -3,8 +3,6 @@ import Foundation /// CSV Fix Parser for parsing FIX_BASE.csv, FIX_NAV.csv, and FIX_CHRT.csv actor CSVFixParser: CSVParser { var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["FIX_BASE.csv", "FIX_NAV.csv", "FIX_CHRT.csv"] var fixes = [FixKey: Fix]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVHoldParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVHoldParser.swift index cbf9e70..d299121 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVHoldParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVHoldParser.swift @@ -8,8 +8,6 @@ actor CSVHoldParser: CSVParser, DiagnosingParser { static let type = RecordType.holds var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["HPF_BASE.csv", "HPF_CHRT.csv", "HPF_SPD_ALT.csv", "HPF_RMK.csv"] var holds = [String: Hold]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVILSParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVILSParser.swift index 6dcf1cb..8cf0b3a 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVILSParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVILSParser.swift @@ -5,8 +5,6 @@ actor CSVILSParser: CSVParser, DiagnosingParser { static let type = RecordType.ILSes var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["ILS_BASE.csv", "ILS_GS.csv", "ILS_DME.csv", "ILS_MKR.csv", "ILS_RMK.csv"] var ILSFacilities = [ILSKey: ILS]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVLocationIdentifierParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVLocationIdentifierParser.swift index 45c27e7..7c138bb 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVLocationIdentifierParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVLocationIdentifierParser.swift @@ -10,8 +10,6 @@ actor CSVLocationIdentifierParser: CSVParser, DiagnosingParser { static let type = RecordType.locationIdentifiers var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["LID.csv"] var identifiers = [LocationIdentifier]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVMilitaryTrainingRouteParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVMilitaryTrainingRouteParser.swift index 352fad5..09f4212 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVMilitaryTrainingRouteParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVMilitaryTrainingRouteParser.swift @@ -7,8 +7,6 @@ actor CSVMilitaryTrainingRouteParser: CSVParser, DiagnosingParser { static let type = RecordType.militaryTrainingRoutes var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = [ "MTR_BASE.csv", "MTR_AGY.csv", "MTR_PT.csv", "MTR_SOP.csv", "MTR_TERR.csv", "MTR_WDTH.csv" diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVMiscActivityAreaParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVMiscActivityAreaParser.swift index 290ee9d..1e595c1 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVMiscActivityAreaParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVMiscActivityAreaParser.swift @@ -6,8 +6,6 @@ actor CSVMiscActivityAreaParser: CSVParser, DiagnosingParser { static let type = RecordType.miscActivityAreas var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["MAA_BASE.csv", "MAA_CON.csv", "MAA_RMK.csv", "MAA_SHP.csv"] var pendingDiagnostics = [RecordParseError]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVNavaidParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVNavaidParser.swift index 8c528dd..297173c 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVNavaidParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVNavaidParser.swift @@ -6,8 +6,6 @@ actor CSVNavaidParser: CSVParser, DiagnosingParser { static let type = RecordType.navaids var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["NAV_BASE.csv", "NAV_RMK.csv", "NAV_CKPT.csv"] var navaids = [NavaidKey: Navaid]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVParachuteJumpAreaParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVParachuteJumpAreaParser.swift index 4efc6c8..03a8a7c 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVParachuteJumpAreaParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVParachuteJumpAreaParser.swift @@ -6,8 +6,6 @@ actor CSVParachuteJumpAreaParser: CSVParser, DiagnosingParser { static let type = RecordType.parachuteJumpAreas var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["PJA_BASE.csv", "PJA_CON.csv"] var areas = [String: ParachuteJumpArea]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVPreferredRouteParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVPreferredRouteParser.swift index 5c82bfe..92a17f1 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVPreferredRouteParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVPreferredRouteParser.swift @@ -6,8 +6,6 @@ actor CSVPreferredRouteParser: CSVParser, DiagnosingParser { static let type = RecordType.preferredRoutes var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["PFR_BASE.csv", "PFR_SEG.csv"] var routes = [String: PreferredRoute]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVTerminalCommFacilityParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVTerminalCommFacilityParser.swift index 01851d8..9487f7d 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVTerminalCommFacilityParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVTerminalCommFacilityParser.swift @@ -12,8 +12,6 @@ actor CSVTerminalCommFacilityParser: CSVParser, DiagnosingParser { static let type = RecordType.terminalCommFacilities var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = [ "ATC_BASE.csv", "ATC_SVC.csv", "ATC_ATIS.csv", "ATC_RMK.csv", "RDR.csv", "MIL_OPS.csv", "CLS_ARSP.csv" diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVWeatherReportingLocationParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVWeatherReportingLocationParser.swift index 487dd43..d846579 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVWeatherReportingLocationParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVWeatherReportingLocationParser.swift @@ -7,8 +7,6 @@ import StreamingCSV actor CSVWeatherReportingLocationParser: CSVParser, DiagnosingParser { static let type = RecordType.weatherReportingLocations var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["WXL_BASE.csv", "WXL_SVC.csv"] var pendingDiagnostics = [RecordParseError]() diff --git a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVWeatherStationParser.swift b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVWeatherStationParser.swift index c7abec2..406e9e6 100644 --- a/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVWeatherStationParser.swift +++ b/Sources/SwiftNASR/Parsers/CSV/Parsers/CSVWeatherStationParser.swift @@ -5,8 +5,6 @@ import StreamingCSV actor CSVWeatherStationParser: CSVParser, DiagnosingParser { static let type = RecordType.weatherReportingStations var distribution: (any Distribution)? - var progress: Progress? - var bytesRead: Int64 = 0 let CSVFiles = ["AWOS.csv"] var pendingDiagnostics = [RecordParseError]() diff --git a/Sources/SwiftNASR/Parsers/FixedWidthParser/DDMMSSParser.swift b/Sources/SwiftNASR/Parsers/FixedWidthParser/DDMMSSParser.swift index 1a34289..c08e981 100644 --- a/Sources/SwiftNASR/Parsers/FixedWidthParser/DDMMSSParser.swift +++ b/Sources/SwiftNASR/Parsers/FixedWidthParser/DDMMSSParser.swift @@ -1,4 +1,4 @@ -@preconcurrency import RegexBuilder +@unsafe @preconcurrency import RegexBuilder final class DDMMSSParser: Sendable { private let degreesRef = Reference() diff --git a/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthTypes.swift b/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthTypes.swift index 6ab0ca1..544bcc8 100644 --- a/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthTypes.swift +++ b/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthTypes.swift @@ -149,7 +149,7 @@ enum FixedWidthField { case DDMMSS(nullable: Nullable = .notNull) case frequency(nullable: Nullable = .notNull) case boolean(trueValue: String = "Y", nullable: Nullable = .notNull) - case datetime(formatter: DateFormatter, nullable: Nullable = .notNull) + case datetime(strategy: Date.ParseStrategy, nullable: Nullable = .notNull) case dateComponents(format: DateFormat, nullable: Nullable = .notNull) case fixedWidthArray( width: Int = 1, diff --git a/Sources/SwiftNASR/Parsers/LayoutDataParser.swift b/Sources/SwiftNASR/Parsers/LayoutDataParser.swift index a1d2b21..e274266 100644 --- a/Sources/SwiftNASR/Parsers/LayoutDataParser.swift +++ b/Sources/SwiftNASR/Parsers/LayoutDataParser.swift @@ -1,5 +1,5 @@ import Foundation -@preconcurrency import RegexBuilder +@unsafe @preconcurrency import RegexBuilder private final class FieldParser: Sendable { private let lengthRef = Reference() @@ -175,7 +175,7 @@ extension LayoutDataParser { let layoutPath = "Layout_Data/\(type.rawValue.lowercased())_rf.txt" let lines: AsyncThrowingStream = await distribution.readFile( path: layoutPath, - withProgress: { _ in }, + progress: nil, returningLines: { _ in } ) for try await data in lines { diff --git a/Sources/SwiftNASR/Parsers/Parser.swift b/Sources/SwiftNASR/Parsers/Parser.swift index 68b6519..a86f741 100644 --- a/Sources/SwiftNASR/Parsers/Parser.swift +++ b/Sources/SwiftNASR/Parsers/Parser.swift @@ -1,5 +1,5 @@ public import Foundation -@preconcurrency import RegexBuilder +@unsafe @preconcurrency import RegexBuilder public protocol Parser: Actor { func prepare(distribution: any Distribution) async throws diff --git a/Sources/SwiftNASR/Parsers/Record Parsers/AirportParser/AirportParser+RunwayEnd.swift b/Sources/SwiftNASR/Parsers/Record Parsers/AirportParser/AirportParser+RunwayEnd.swift index 34dc178..5ea3449 100644 --- a/Sources/SwiftNASR/Parsers/Record Parsers/AirportParser/AirportParser+RunwayEnd.swift +++ b/Sources/SwiftNASR/Parsers/Record Parsers/AirportParser/AirportParser+RunwayEnd.swift @@ -1,5 +1,5 @@ import Foundation -@preconcurrency import RegexBuilder +@unsafe @preconcurrency import RegexBuilder private final class VGSIParser: Sendable { private let numberRef = Reference() diff --git a/Sources/SwiftNASR/Parsers/Record Parsers/FSSParser/FrequencyParser.swift b/Sources/SwiftNASR/Parsers/Record Parsers/FSSParser/FrequencyParser.swift index 3db3ff6..d21fac4 100644 --- a/Sources/SwiftNASR/Parsers/Record Parsers/FSSParser/FrequencyParser.swift +++ b/Sources/SwiftNASR/Parsers/Record Parsers/FSSParser/FrequencyParser.swift @@ -1,5 +1,5 @@ import Foundation -@preconcurrency import RegexBuilder +@unsafe @preconcurrency import RegexBuilder final class FrequencyParser: Sendable { private let MHzRef = Reference() diff --git a/Sources/SwiftNASR/RecordParseError.swift b/Sources/SwiftNASR/RecordParseError.swift index f59caff..7ce04ac 100644 --- a/Sources/SwiftNASR/RecordParseError.swift +++ b/Sources/SwiftNASR/RecordParseError.swift @@ -4,14 +4,14 @@ public import Foundation public enum ParseDisposition: Sendable { /// Continue parsing subsequent records. case proceed - /// Stop parsing; ``NASR/parse(_:withProgress:errorHandler:)`` returns `false`. + /// Stop parsing; ``NASR/parse(_:progress:errorHandler:)`` returns `false`. case abort } /// A problem encountered while parsing a single record from a NASR distribution. /// /// Reported through the `errorHandler` of -/// ``NASR/parse(_:withProgress:errorHandler:)``. A +/// ``NASR/parse(_:progress:errorHandler:)``. A /// ``recordError(recordType:recordID:underlying:)`` means the record could not /// be constructed and was omitted; a /// ``fieldError(recordType:recordID:field:value:underlying:)`` means the record diff --git a/Sources/SwiftNASR/Support/ByteReadProgress.swift b/Sources/SwiftNASR/Support/ByteReadProgress.swift new file mode 100644 index 0000000..37c893f --- /dev/null +++ b/Sources/SwiftNASR/Support/ByteReadProgress.swift @@ -0,0 +1,50 @@ +internal import Foundation + +/** + Reports a byte-oriented read to a `ProgressManager`, keeping the manager's unit counts and its + `totalByteCount`/`completedByteCount` properties in step. + + A read starts out indeterminate. Every reader in this package hands its caller a stream before it + knows how large the read will be — a file's size, an archive entry's uncompressed size, or a + response's `Content-Length` — so the total arrives through ``setTotal(_:)`` once it is known. + + A `nil` subprogress means the caller asked for no progress reporting, in which case every method + here does nothing. + */ +struct ByteReadProgress: Sendable { + private let manager: ProgressManager? + + /// Begins reporting into `subprogress`, or reports nothing if it is `nil`. + init(_ subprogress: consuming Subprogress?) { + manager = subprogress?.start(totalCount: nil) + } + + /// Declares how many bytes the read covers in total. + func setTotal(_ bytes: Int) { + manager?.setCounts { _, total in total = bytes } + manager?.totalByteCount = UInt64(clamping: bytes) + } + + /// Records that `bytes` further bytes have been read. + func advance(by bytes: Int) { + manager?.complete(count: bytes) + manager?.completedByteCount += UInt64(clamping: bytes) + } + + /// Records an absolute position in a read whose total the reader learns as it goes, such as a + /// download reporting against a `Content-Length` that only arrives with the first response. + func update(completed: Int, total: Int) { + manager?.setCounts { completedCount, totalCount in + completedCount = completed + totalCount = total + } + manager?.totalByteCount = UInt64(clamping: total) + manager?.completedByteCount = UInt64(clamping: completed) + } +} + +/// Marks a subprogress finished without tracking any work, for the loaders and readers that have +/// nothing to report because their work is already done by the time they are called. +func completeImmediately(_ subprogress: consuming Subprogress?) { + subprogress?.start(totalCount: 1).complete(count: 1) +} diff --git a/Tests/SwiftNASRTests/CycleTests.swift b/Tests/SwiftNASRTests/CycleTests.swift index 8554371..e3e2593 100644 --- a/Tests/SwiftNASRTests/CycleTests.swift +++ b/Tests/SwiftNASRTests/CycleTests.swift @@ -141,4 +141,38 @@ struct CycleTests { // expirationDate should equal the next cycle's effectiveDate #expect(expirationDate == cycle.next?.effectiveDate) } + + // MARK: parseReadmeCycleDate + + @Test(arguments: [ + "October 32, 2025", // day out of range for the month + "February 30, 2025", + "October 30, 2025 and more words", // trailing text + "October 30, 2025extra", + "Octobre 30, 2025", // not the README's locale + "10/30/2025", // not the README's format + "" + ]) + func `rejects a malformed README effective date`(_ input: String) { + #expect(parseReadmeCycleDate(input) == nil) + } + + @Test(arguments: [ + ("October 30, 2025", 2025, 10, 30), + ("January 1, 2026", 2026, 1, 1), + ("December 31, 2025", 2025, 12, 31), + (" March 5, 2024 ", 2024, 3, 5) // surrounding whitespace is tolerated + ]) + func `parses a well-formed README effective date`( + _ input: String, + _ year: Int, + _ month: Int, + _ day: Int + ) throws { + let date = try #require(parseReadmeCycleDate(input)) + let components = calendar.dateComponents([.year, .month, .day], from: date) + #expect(components.year == year) + #expect(components.month == month) + #expect(components.day == day) + } } diff --git a/Tests/SwiftNASRTests/Distribution/ArchiveDataDistributionTests.swift b/Tests/SwiftNASRTests/Distribution/ArchiveDataDistributionTests.swift index a906604..1fb273f 100644 --- a/Tests/SwiftNASRTests/Distribution/ArchiveDataDistributionTests.swift +++ b/Tests/SwiftNASRTests/Distribution/ArchiveDataDistributionTests.swift @@ -56,10 +56,13 @@ struct ArchiveDataDistributionTests { @Test func `reads each line from the file`() async throws { var iter = 0 - var progress = Progress(totalUnitCount: 0) + let progress = ProgressManager(totalCount: 21) - let stream = await distributionReadme.readFile(path: "APT.TXT") { progress = $0 } - #expect(progress.completedUnitCount == 21) + let stream = await distributionReadme.readFile( + path: "APT.TXT", + progress: progress.subprogress(assigningCount: 21) + ) + #expect(progress.completedCount == 21) for try await data in stream { if iter == 0 { diff --git a/Tests/SwiftNASRTests/Distribution/ArchiveFileDistributionTests.swift b/Tests/SwiftNASRTests/Distribution/ArchiveFileDistributionTests.swift index 37884dc..ceb549a 100644 --- a/Tests/SwiftNASRTests/Distribution/ArchiveFileDistributionTests.swift +++ b/Tests/SwiftNASRTests/Distribution/ArchiveFileDistributionTests.swift @@ -32,10 +32,13 @@ struct ArchiveFileDistributionTests { defer { try? FileManager.default.removeItem(at: tempfile) } var iter = 0 - var progress = Progress(totalUnitCount: 0) + let progress = ProgressManager(totalCount: 21) - let stream = await distribution.readFile(path: "APT.TXT") { progress = $0 } - #expect(progress.completedUnitCount == 21) + let stream = await distribution.readFile( + path: "APT.TXT", + progress: progress.subprogress(assigningCount: 21) + ) + #expect(progress.completedCount == 21) for try await data in stream { if iter == 0 { diff --git a/Tests/SwiftNASRTests/Distribution/DirectoryDistributionTests.swift b/Tests/SwiftNASRTests/Distribution/DirectoryDistributionTests.swift index 3fc2245..e130b55 100644 --- a/Tests/SwiftNASRTests/Distribution/DirectoryDistributionTests.swift +++ b/Tests/SwiftNASRTests/Distribution/DirectoryDistributionTests.swift @@ -22,13 +22,15 @@ struct DirectoryDistributionTests { defer { try? FileManager.default.removeItem(at: tempdir) } var iter = 0 - var progress = Progress(totalUnitCount: 0) + let progress = ProgressManager(totalCount: 21) - let stream = await distribution.readFile(path: "APT.TXT") { progress = $0 } + let stream = await distribution.readFile( + path: "APT.TXT", + progress: progress.subprogress(assigningCount: 21) + ) for try await data in stream { if iter == 0 { - #expect(progress.completedUnitCount == 35) #expect(data == "Hello, world!".data(using: .isoLatin1)!) } else if iter == 1 { #expect(data == "Line 2".data(using: .isoLatin1)!) @@ -38,6 +40,13 @@ struct DirectoryDistributionTests { iter += 1 } + + // A 21-byte file reports 21 bytes read, counted once. Adding each line's length on top of the + // chunk it arrived in would report 35, overshooting the read's own total. + let bytesRead: UInt64 = progress.summary(of: \.completedByteCount) + let fileSize: UInt64 = progress.summary(of: \.totalByteCount) + #expect(bytesRead == 21) + #expect(fileSize == 21) } @Test diff --git a/Tests/SwiftNASRTests/Parsers/CSVRowDropTests.swift b/Tests/SwiftNASRTests/Parsers/CSVRowDropTests.swift index 7168207..037f943 100644 --- a/Tests/SwiftNASRTests/Parsers/CSVRowDropTests.swift +++ b/Tests/SwiftNASRTests/Parsers/CSVRowDropTests.swift @@ -8,8 +8,6 @@ private actor RowCountingParser: CSVParser, DiagnosingParser { static let type = RecordType.navaids var distribution: (any Distribution)? let CSVFiles = ["TEST.csv"] - var progress: Progress? - var bytesRead: Int64 = 0 var pendingDiagnostics = [RecordParseError]() var kept = [String]() diff --git a/Tests/SwiftNASR_E2E/ProgressTracker.swift b/Tests/SwiftNASR_E2E/ProgressTracker.swift index d141c7b..17c412b 100644 --- a/Tests/SwiftNASR_E2E/ProgressTracker.swift +++ b/Tests/SwiftNASR_E2E/ProgressTracker.swift @@ -1,81 +1,75 @@ import Foundation +import Observation +import Synchronization -actor ProgressTracker { - var progress: Progress - var isStarted = false - var currentRecordType: String? +/// Root progress for one format's load-and-parse run, plus the label shown beside the bar. +final class ProgressTracker: Sendable { - var fractionCompleted: Double { progress.fractionCompleted } + /// The progress every load and parse reports into. Hand out `subprogress(assigningCount:)` to + /// give a phase its share of the run. + let manager: ProgressManager - var isFinished: Bool { isStarted && progress.isFinished } + private let recordType = Mutex(nil) - init() { - self.progress = Progress(totalUnitCount: 100) - } + /// The record type currently being parsed, if any. + var currentRecordType: String? { recordType.withLock { $0 } } - func addChild(_ child: Progress, withPendingUnitCount inUnitCount: Int64) { - self.progress.addChild(child, withPendingUnitCount: inUnitCount) - isStarted = true + init(totalCount: Int) { + manager = ProgressManager(totalCount: totalCount) } func setCurrentRecordType(_ recordType: String?) { - self.currentRecordType = recordType - } - - func reset(totalUnitCount: Int64) { - self.progress = Progress(totalUnitCount: totalUnitCount) - isStarted = false - currentRecordType = nil + self.recordType.withLock { $0 = recordType } } } // MARK: - Progress Display -func trackProgress(progress: ProgressTracker) -> Task { - Task.detached { - repeat { - try await Task.sleep(for: .seconds(0.1)) - await renderProgressBar(progress: progress) - } while await !progress.isFinished +/// Draws a progress bar for `tracker`, redrawing each time its completed percentage changes and +/// stopping once its progress finishes. +/// +/// `ProgressManager` is `Observable`, so the bar follows the run's actual progress rather than a +/// timer. +func trackProgress(progress tracker: ProgressTracker) -> Task { + let manager = tracker.manager + return Task { + var lastPercent = -1 + let percentages = Observations.untilFinished { + manager.isFinished ? .finish : .next(percentComplete(of: manager)) + } + for await percent in percentages where percent != lastPercent { + lastPercent = percent + await renderProgressBar(percent: percent, recordType: tracker.currentRecordType) + } + if manager.isFinished { await renderProgressBar(percent: 100, recordType: nil) } } } -@MainActor -func renderProgressBar(progress: ProgressTracker) async { - let fractionCompleted = await progress.fractionCompleted - let currentRecordType = await progress.currentRecordType - let percent = Int((fractionCompleted * 100).rounded()) +private func percentComplete(of manager: ProgressManager) -> Int { + let clampedFraction = max(0, min(1, manager.fractionCompleted)) + return Int((clampedFraction * 100).rounded()) +} +@MainActor +func renderProgressBar(percent: Int, recordType: String?) { // Build the status suffix (e.g., " - Parsing airports...") - let statusSuffix: String - if let recordType = currentRecordType { - statusSuffix = " - Parsing \(recordType)..." - } else { - statusSuffix = "" - } + let statusSuffix = recordType.map { " - Parsing \($0)..." } ?? "" // Reserve space for percentage, brackets, and status let reservedSpace = 10 + statusSuffix.count let barWidth = max(terminalWidth() - reservedSpace, 10) // Ensure minimum bar width - // Ensure fractionCompleted is within valid bounds - let clampedFraction = max(0.0, min(1.0, fractionCompleted)) - let completedWidth = Int(clampedFraction * Double(barWidth)) - - // Ensure counts are non-negative - let safeCompletedWidth = max(0, min(barWidth, completedWidth)) - let safeRemainingWidth = max(0, barWidth - safeCompletedWidth) - + let completedWidth = max(0, min(barWidth, barWidth * percent / 100)) let bar = - String(repeating: "=", count: safeCompletedWidth) - + String(repeating: " ", count: safeRemainingWidth) + String(repeating: "=", count: completedWidth) + + String(repeating: " ", count: barWidth - completedWidth) print("\r[\(bar)] \(percent)%\(statusSuffix)", terminator: "") fflush(nil) // Ensure that all open output streams are flushed immediately } func terminalWidth() -> Int { var w = winsize() - if ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &w) == 0 { + if unsafe ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &w) == 0 { return Int(w.ws_col) } return 80 diff --git a/Tests/SwiftNASR_E2E/ProgressWeights.swift b/Tests/SwiftNASR_E2E/ProgressWeights.swift index 46904eb..5cc26a3 100644 --- a/Tests/SwiftNASR_E2E/ProgressWeights.swift +++ b/Tests/SwiftNASR_E2E/ProgressWeights.swift @@ -1,10 +1,10 @@ import SwiftNASR /// Weight for the initial loading phase (relative to parsing weights). -let loadingWeight: Int64 = 10 +let loadingWeight: Int = 10 /// Returns the weight for a record type based on format. -func weight(for recordType: RecordType, isCSV: Bool) -> Int64 { +func weight(for recordType: RecordType, isCSV: Bool) -> Int { recordTypeRegistry[recordType]?.weight(isCSV: isCSV) ?? 1 } @@ -19,7 +19,7 @@ var CSVRecordTypes: Set { } /// Calculates the total progress weight for selected record types. -func totalWeight(isCSV: Bool, selectedRecordTypes: Set) -> Int64 { +func totalWeight(isCSV: Bool, selectedRecordTypes: Set) -> Int { let recordTotal = selectedRecordTypes.reduce(0) { $0 + weight(for: $1, isCSV: isCSV) } return loadingWeight + recordTotal } diff --git a/Tests/SwiftNASR_E2E/RecordTypeRegistry.swift b/Tests/SwiftNASR_E2E/RecordTypeRegistry.swift index 6742cba..8ed43af 100644 --- a/Tests/SwiftNASR_E2E/RecordTypeRegistry.swift +++ b/Tests/SwiftNASR_E2E/RecordTypeRegistry.swift @@ -4,13 +4,13 @@ import SwiftNASR struct RecordTypeInfo { let recordType: RecordType let displayName: String - let txtWeight: Int64? // nil if not available in TXT - let csvWeight: Int64? // nil if not available in CSV + let txtWeight: Int? // nil if not available in TXT + let csvWeight: Int? // nil if not available in CSV var availableInTXT: Bool { txtWeight != nil } var availableInCSV: Bool { csvWeight != nil } - func weight(isCSV: Bool) -> Int64 { + func weight(isCSV: Bool) -> Int { (isCSV ? csvWeight : txtWeight) ?? 1 } } diff --git a/Tests/SwiftNASR_E2E/SwiftNASR_E2E.swift b/Tests/SwiftNASR_E2E/SwiftNASR_E2E.swift index 2cd0b86..b4b1474 100644 --- a/Tests/SwiftNASR_E2E/SwiftNASR_E2E.swift +++ b/Tests/SwiftNASR_E2E/SwiftNASR_E2E.swift @@ -40,8 +40,6 @@ struct SwiftNASR_E2E: AsyncParsableCommand { workingDirectory.appendingPathComponent("distribution_csv.zip") } - private let progress = ProgressTracker() - init() {} mutating func getTxtNASR() -> NASR? { @@ -146,24 +144,24 @@ struct SwiftNASR_E2E: AsyncParsableCommand { ) async throws { let isCSV = formatName.lowercased() == "csv" let effectiveRecordTypes = effectiveTypes(for: isCSV, selectedRecordTypes: selectedRecordTypes) - await progress.reset( - totalUnitCount: totalWeight(isCSV: isCSV, selectedRecordTypes: effectiveRecordTypes) + let progress = ProgressTracker( + totalCount: totalWeight(isCSV: isCSV, selectedRecordTypes: effectiveRecordTypes) ) print("Loading \(formatName)…") - let progress = self.progress - try await nasr.load { child in - Task { @MainActor in await progress.addChild(child, withPendingUnitCount: loadingWeight) } - } + try await nasr.load(progress: progress.manager.subprogress(assigningCount: loadingWeight)) print("Done loading \(formatName); parsing…") - _ = trackProgress(progress: progress) + let progressBar = trackProgress(progress: progress) let errorCollector = ErrorCollector() try await parseValues( nasr: nasr, isCSV: isCSV, errorCollector: errorCollector, - selectedRecordTypes: effectiveRecordTypes + selectedRecordTypes: effectiveRecordTypes, + progress: progress ) + progressBar.cancel() + await progressBar.value // Clear the progress line before printing results print("\r" + String(repeating: " ", count: terminalWidth()) + "\r", terminator: "") @@ -212,21 +210,9 @@ struct SwiftNASR_E2E: AsyncParsableCommand { nasr: NASR, isCSV: Bool, errorCollector: ErrorCollector, - selectedRecordTypes: Set + selectedRecordTypes: Set, + progress: ProgressTracker ) async throws { - let progress = self.progress - - // Helper to create progress handler for a record type - func progressHandler(for recordType: RecordType) -> @Sendable (Progress) -> Void { - let recordWeight = weight(for: recordType, isCSV: isCSV) - return { child in - Task { @MainActor in - await progress.setCurrentRecordType(String(describing: recordType)) - await progress.addChild(child, withPendingUnitCount: recordWeight) - } - } - } - // Helper to create error handler for a record type func errorHandler(for recordType: RecordType) -> @Sendable (RecordParseError) -> ParseDisposition @@ -249,10 +235,12 @@ struct SwiftNASR_E2E: AsyncParsableCommand { // Parse all selected record types concurrently try await withThrowingTaskGroup(of: Void.self) { group in for recordType in selectedRecordTypes { + let recordWeight = weight(for: recordType, isCSV: isCSV) group.addTask { + progress.setCurrentRecordType(String(describing: recordType)) _ = try await nasr.parse( recordType, - withProgress: progressHandler(for: recordType), + progress: progress.manager.subprogress(assigningCount: recordWeight), errorHandler: errorHandler(for: recordType) ) } @@ -261,7 +249,7 @@ struct SwiftNASR_E2E: AsyncParsableCommand { } // Clear current record type when done - await progress.setCurrentRecordType(nil) + progress.setCurrentRecordType(nil) } /// Parses the record types option into a set of RecordType values. diff --git a/Tests/SwiftNASR_Simple/main.swift b/Tests/SwiftNASR_Simple/main.swift index a549351..6afe9ae 100644 --- a/Tests/SwiftNASR_Simple/main.swift +++ b/Tests/SwiftNASR_Simple/main.swift @@ -21,9 +21,9 @@ if FileManager.default.fileExists(atPath: txtPath.path) { print("About to call nasr.load()...") do { - try await nasr.load { progress in - print("Load progress: \(progress.fractionCompleted)") - } + let progress = ProgressManager(totalCount: 1) + try await nasr.load(progress: progress.subprogress(assigningCount: 1)) + print("Load progress: \(progress.fractionCompleted)") print("Load completed successfully!") } catch { print("Load failed: \(error)")