diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 178e677..051efaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7323369..99c854a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Package.swift b/Package.swift index 1528478..aaa9b73 100644 --- a/Package.swift +++ b/Package.swift @@ -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", diff --git a/Sources/SwiftCIFP/Cycle.swift b/Sources/SwiftCIFP/Cycle.swift index a3a5644..8d06719 100644 --- a/Sources/SwiftCIFP/Cycle.swift +++ b/Sources/SwiftCIFP/Cycle.swift @@ -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) } } diff --git a/Sources/SwiftCIFP/Parser/ByteParsing.swift b/Sources/SwiftCIFP/Parser/ByteParsing.swift index c02342c..60b2cce 100644 --- a/Sources/SwiftCIFP/Parser/ByteParsing.swift +++ b/Sources/SwiftCIFP/Parser/ByteParsing.swift @@ -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. @@ -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) { + guard let utf8 = try? UTF8Span(validating: unsafe bytes.span) else { + self = "" + return + } + self.init(copying: utf8) + } +} diff --git a/Sources/SwiftCIFP/Parser/CIFPLineReader.swift b/Sources/SwiftCIFP/Parser/CIFPLineReader.swift index 1205f88..5e9642b 100644 --- a/Sources/SwiftCIFP/Parser/CIFPLineReader.swift +++ b/Sources/SwiftCIFP/Parser/CIFPLineReader.swift @@ -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) @@ -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") } @@ -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 @@ -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) + } + } } } @@ -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 { diff --git a/Sources/SwiftCIFP/Types/Coordinate.swift b/Sources/SwiftCIFP/Types/Coordinate.swift index a8ef324..b30c57e 100644 --- a/Sources/SwiftCIFP/Types/Coordinate.swift +++ b/Sources/SwiftCIFP/Types/Coordinate.swift @@ -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 + ) } } diff --git a/Sources/SwiftCIFP/Types/MagneticVariation.swift b/Sources/SwiftCIFP/Types/MagneticVariation.swift index 9e8ba60..27b35c0 100644 --- a/Sources/SwiftCIFP/Types/MagneticVariation.swift +++ b/Sources/SwiftCIFP/Types/MagneticVariation.swift @@ -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)) } } diff --git a/Sources/SwiftCIFP_E2E/OutputFormatter.swift b/Sources/SwiftCIFP_E2E/OutputFormatter.swift index dd81808..d2d1767 100644 --- a/Sources/SwiftCIFP_E2E/OutputFormatter.swift +++ b/Sources/SwiftCIFP_E2E/OutputFormatter.swift @@ -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) } } @@ -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() @@ -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) } } } diff --git a/Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift b/Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift index eb0d61a..2feff54 100644 --- a/Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift +++ b/Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift @@ -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