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
14 changes: 4 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,3 @@
# 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.0): macos-15 + 6.0, macos-15 + 6.2, macos-26 + 6.2
# Linux: ubuntu + Swift 6.3
#
# When Swift 6.3 ships: bump 6.0→6.1 and 6.2→6.3 in Category C
# When bumping tools-version to 6.2: drop 6.0/6.1, move to Category A or B

name: CI

on:
Expand All @@ -29,8 +19,12 @@ jobs:
include:
- os: macos-26
swift: "6.2"
- os: macos-26
swift: "6.4"
- os: ubuntu-latest
swift: "6.3"
- os: ubuntu-latest
swift: "6.4"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## [Unreleased]

## [1.3.0] - 2026-09-14

### Changed

- Lowered the platform floor to macOS 13, iOS 16, watchOS 9, tvOS 16, and
visionOS 1, down from 26 on every platform. The manifest had required
releases far newer than anything the package uses — `TimeZone.gmt` is the
newest API it touches — so apps that have not moved to the 26 releases can
now adopt the library unchanged.
- Raised the minimum version of two package dependencies: swift-argument-parser
1.8.2 and swift-docc-plugin 1.5.0.

### Fixed

- Parsing a truncated record no longer traps. `slice(_:)` clamped only its upper
Expand Down
5 changes: 3 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ let upcomingFeatures: [SwiftSetting] = [
.enableUpcomingFeature("ImmutableWeakCaptures"),
.enableUpcomingFeature("MemberImportVisibility"),
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InternalImportsByDefault")
.enableUpcomingFeature("InternalImportsByDefault"),
.strictMemorySafety()
]

let package = Package(
name: "SwiftCIFP",
defaultLocalization: "en",
platforms: [.macOS(.v13), .iOS(.v16), .watchOS(.v9), .tvOS(.v16), .visionOS(.v1)],
platforms: [.macOS(.v26), .iOS(.v26), .watchOS(.v26), .tvOS(.v26), .visionOS(.v26)],
products: [
.library(
name: "SwiftCIFP",
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftCIFP/Cycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ extension Cycle {
extension Cycle {
/// The YYMM string representation (used internally for identification).
var yymm: String {
String(format: "%02d%02d", year % 100, cycleNumber)
unsafe String(format: "%02d%02d", year % 100, cycleNumber)
}
}

Expand Down
22 changes: 19 additions & 3 deletions Sources/SwiftCIFP/Parser/ByteParsing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,17 @@ extension RandomAccessCollection where Element == UInt8, Index == Int {
/// Convert to trimmed String (only when actually needed).
@inlinable
func toString() -> String {
guard let string = String(bytes: Array(self), encoding: .utf8) else { return "" }
return string.trimmingCharacters(in: .whitespaces)
toRawString().trimmingCharacters(in: .whitespaces)
}

/// Convert to raw String without trimming (for exact matching like section codes).
///
/// Decodes in place over the collection's own storage, so no intermediate array is
/// allocated per field. Bytes that are not valid UTF-8 yield an empty string.
@inlinable
func toRawString() -> String {
String(bytes: Array(self), encoding: .utf8) ?? ""
unsafe (withContiguousStorageIfAvailable(String.init(validatingUTF8Bytes:))
?? ContiguousArray(self).withUnsafeBufferPointer(String.init(validatingUTF8Bytes:)))
}

/// Check if all bytes are whitespace.
Expand All @@ -127,3 +130,16 @@ extension RandomAccessCollection where Element == UInt8, Index == Int {
allSatisfy { $0 == ASCII.space }
}
}

extension String {
/// Create a string from contiguous UTF-8 bytes without copying them into an
/// intermediate array, yielding an empty string when the bytes are not valid UTF-8.
@inlinable
init(validatingUTF8Bytes bytes: UnsafeBufferPointer<UInt8>) {
guard let utf8 = try? UTF8Span(validating: unsafe bytes.span) else {
self = ""
return
}
self.init(copying: utf8)
}
}
32 changes: 24 additions & 8 deletions Sources/SwiftCIFP/Parser/CIFPLineReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ struct CIFPLineReader: Sequence, IteratorProtocol, Sendable {
lineBuffer.removeAll(keepingCapacity: true)

// Scan until LF or end of data
data.withUnsafeBytes { buffer in
let bytes = buffer.bindMemory(to: UInt8.self)
unsafe data.withUnsafeBytes { buffer in
let bytes = unsafe buffer.bindMemory(to: UInt8.self)
while position < bytes.count {
let byte = bytes[position]
let byte = unsafe bytes[position]
position += 1
if byte == ASCII.LF { return }
lineBuffer.append(byte)
Expand Down Expand Up @@ -99,12 +99,12 @@ struct AsyncCIFPLineReader: AsyncSequence, Sendable {
self.lineBuffer.reserveCapacity(lineBufferCapacity)
}

mutating func next() throws -> [UInt8]? {
mutating func next() throws(CIFPError) -> [UInt8]? {
guard !isEOF else { return nil }

// Lazily open file handle on first call
if handle == nil {
handle = try FileHandle(forReadingFrom: url)
handle = try openFile()
}
guard let handle else { preconditionFailure("handle was nil") }

Expand All @@ -113,7 +113,7 @@ struct AsyncCIFPLineReader: AsyncSequence, Sendable {
while true {
// Refill buffer if exhausted
if bufferPos >= buffer.count {
guard let chunk = try handle.read(upToCount: bufferSize),
guard let chunk = try readChunk(from: handle),
!chunk.isEmpty
else {
isEOF = true
Expand All @@ -138,6 +138,22 @@ struct AsyncCIFPLineReader: AsyncSequence, Sendable {
lineBuffer.append(byte)
}
}

private func openFile() throws(CIFPError) -> FileHandle {
do {
return try FileHandle(forReadingFrom: url)
} catch {
throw .streamError(error)
}
}

private func readChunk(from handle: FileHandle) throws(CIFPError) -> Data? {
do {
return try handle.read(upToCount: bufferSize)
} catch {
throw .streamError(error)
}
}
}
}

Expand Down Expand Up @@ -172,10 +188,10 @@ where Source.Element == UInt8, Source: Sendable {
}

@concurrent
mutating func next() async throws -> [UInt8]? {
mutating func next() async throws(Source.Failure) -> [UInt8]? {
lineBuffer.removeAll(keepingCapacity: true)

while let byte = try await iterator.next() {
while let byte = try await iterator.next(isolation: nil) {
if byte == ASCII.LF {
// Strip trailing CR if present (handles CRLF)
if lineBuffer.last == ASCII.CR {
Expand Down
8 changes: 7 additions & 1 deletion Sources/SwiftCIFP/Types/Coordinate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ extension Coordinate: CustomStringConvertible {
public var description: String {
let latDir = latitudeDeg >= 0 ? "N" : "S"
let lonDir = longitudeDeg >= 0 ? "E" : "W"
return String(format: "%.6f°%@, %.6f°%@", abs(latitudeDeg), latDir, abs(longitudeDeg), lonDir)
return unsafe String(
format: "%.6f°%@, %.6f°%@",
abs(latitudeDeg),
latDir,
abs(longitudeDeg),
lonDir
)
}
}
2 changes: 1 addition & 1 deletion Sources/SwiftCIFP/Types/MagneticVariation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,6 @@ extension MagneticVariation {

extension MagneticVariation: CustomStringConvertible {
public var description: String {
String(format: "%.1f°%@", degrees, String(direction.rawValue))
unsafe String(format: "%.1f°%@", degrees, String(direction.rawValue))
}
}
16 changes: 9 additions & 7 deletions Sources/SwiftCIFP_E2E/OutputFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ protocol OutputFormatter {
extension OutputStream {
func write(_ string: String) {
guard let data = string.data(using: .utf8) else { return }
data.withUnsafeBytes { buffer in
guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return }
write(pointer, maxLength: buffer.count)
unsafe data.withUnsafeBytes { buffer in
guard let pointer = unsafe buffer.baseAddress?.assumingMemoryBound(to: UInt8.self)
else { return }
unsafe write(pointer, maxLength: buffer.count)
}
}

Expand All @@ -39,7 +40,7 @@ struct SummaryOutputFormatter: OutputFormatter {
stream.writeLine()
stream.writeLine("=== CIFP Summary ===")
stream.writeLine("Cycle: \(cifp.cycle)")
stream.writeLine("Parse time: \(String(format: "%.2f", elapsed)) seconds")
stream.writeLine("Parse time: \(unsafe String(format: "%.2f", elapsed)) seconds")
stream.writeLine("Errors: \(errorCount)")
stream.writeLine()

Expand Down Expand Up @@ -99,9 +100,10 @@ struct JSONOutputFormatter: OutputFormatter {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let jsonData = try encoder.encode(snapshot)
jsonData.withUnsafeBytes { buffer in
guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return }
stream.write(pointer, maxLength: buffer.count)
unsafe jsonData.withUnsafeBytes { buffer in
guard let pointer = unsafe buffer.baseAddress?.assumingMemoryBound(to: UInt8.self)
else { return }
unsafe stream.write(pointer, maxLength: buffer.count)
}
}
}
4 changes: 2 additions & 2 deletions Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ struct SwiftCIFP_E2E: AsyncParsableCommand {
guard let (year, month, day) = dateComponents(from: effectiveDate) else {
throw ValidationError("Failed to extract date components from cycle")
}
let filename = String(format: Self.cifpFilenameFormat, year % 100, month, day)
guard let url = URL(string: String(format: Self.cifpURLFormat, filename)) else {
let filename = unsafe String(format: Self.cifpFilenameFormat, year % 100, month, day)
guard let url = URL(string: unsafe String(format: Self.cifpURLFormat, filename)) else {
throw ValidationError("Failed to construct CIFP URL")
}
return url
Expand Down