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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -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
65 changes: 43 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -57,20 +78,20 @@ 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:
path: |
.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 \
Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/doc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,21 @@ 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:
path: |
.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 \
Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/periphery.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,24 @@ 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:
path: |
.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
Expand Down
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 7 additions & 9 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 6.3
// swift-tools-version: 6.4

import PackageDescription

Expand All @@ -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(
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftNASR/Cycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
35 changes: 13 additions & 22 deletions Sources/SwiftNASR/Distribution/ArchiveDataDistribution.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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<Data, any Swift.Error> {
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)
Expand All @@ -116,8 +116,9 @@ public final class ArchiveDataDistribution: Distribution {

public func readFileRaw(
path: String,
withProgress progressHandler: (Progress) -> Void = { _ in }
progress: consuming Subprogress? = nil
) -> AsyncThrowingStream<Data, any Swift.Error> {
let progress = ByteReadProgress(progress)
return AsyncThrowingStream { continuation in
do {
// Try exact match first, then case-insensitive match
Expand All @@ -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))
}
Expand All @@ -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)
Expand All @@ -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
}

Expand Down
Loading
Loading