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
12 changes: 8 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,14 @@
#
# 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
# Category C (tools-version 6.3): macos-15 + 6.3/6.4, macos-26 + 6.3/6.4
# Linux: ubuntu + Swift 6.3/6.4
# (Package.swift excludes METARFormatting/DecodeMETAR/DecodeTAF on Linux — they format
# Measurement values via APIs swift-corelibs-foundation doesn't implement; SwiftMETAR
# core + DecodeWindsAloft + tests are unaffected and build/test on Linux normally)
#
# This package is Category C. BuildableMacro requires tools-version 6.3, so Swift
# 6.1 and 6.2 can no longer resolve it.
#
# When Swift 6.4 ships: add 6.4 legs alongside 6.3

name: CI

Expand All @@ -34,10 +32,16 @@ jobs:
include:
- os: macos-15
swift: "6.3"
- os: macos-15
swift: "6.4"
- os: macos-26
swift: "6.3"
- 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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Change Log

## [Unreleased]

### Changed

- Every throwing API now declares its error type: `throws(Error)` replaces
untyped `throws` across the parsers and on public entry points such as
`METAR.from(string:on:)`, `TAF.from(string:on:)`, and
`CodedRepresentable.init(coded:)`. Callers need no changes — `catch` blocks
that matched `SwiftMETAR.Error` keep working, and exhaustive `catch` over the
enum's cases is now possible without a fallback clause.
- Case-set regexes (`Weather.Phenomenon`, `Remark.Coverage`, and the other
`RawRepresentable` code sets) are built with RegexBuilder and cached per type
instead of being assembled as a pattern string and recompiled on every access.
- ISO 8601 timestamps in aviationweather.gov XML are parsed with
`Date.ISO8601FormatStyle` rather than `ISO8601DateFormatter`.

## [4.1.0] - 2026-09-14

### Changed
Expand Down
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ let upcomingFeatures: [SwiftSetting] = [
.enableUpcomingFeature("ImmutableWeakCaptures"),
.enableUpcomingFeature("MemberImportVisibility"),
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InternalImportsByDefault")
.enableUpcomingFeature("InternalImportsByDefault"),
.strictMemorySafety()
]

// METARFormatting (and DecodeMETAR/DecodeTAF, which depend on it) format `Measurement`
Expand Down
6 changes: 3 additions & 3 deletions Sources/SwiftMETAR/Common/Altimeter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ public enum Altimeter: CodedRepresentable, Comparable, Sendable {
*/
public var codedString: String {
switch self {
case .inHg(let value): "A\(String(format: "%04d", value))"
case .hPa(let value): "Q\(String(format: "%04d", value))"
case .inHg(let value): "A\(unsafe String(format: "%04d", value))"
case .hPa(let value): "Q\(unsafe String(format: "%04d", value))"
}
}

Expand All @@ -40,7 +40,7 @@ public enum Altimeter: CodedRepresentable, Comparable, Sendable {
- Throws: ``Error/invalidAltimeter(_:)`` if `coded` is not a valid altimeter
setting.
*/
public init(coded: String) throws {
public init(coded: String) throws(Error) {
var parts = coded.split(whereSeparator: \.isWhitespace)
guard let altimeter = try AltimeterParser().parseMETAR(&parts), parts.isEmpty else {
throw Error.invalidAltimeter(coded)
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftMETAR/Common/CodedRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public protocol CodedRepresentable: Codable {
- Parameter coded: The coded string, e.g. `"03015KT"`.
- Throws: An ``Error`` if `coded` is not a valid representation.
*/
init(coded: String) throws
init(coded: String) throws(Error)
}

extension CodedRepresentable {
Expand Down
4 changes: 2 additions & 2 deletions Sources/SwiftMETAR/Common/Condition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public enum Condition: CodedRepresentable, Equatable, Sendable {
- Throws: ``Error/invalidConditions(_:)`` if `coded` is not a valid
representation of a single sky condition.
*/
public init(coded: String) throws {
public init(coded: String) throws(Error) {
if coded == "CAVOK" {
self = .cavok
return
Expand All @@ -123,7 +123,7 @@ public enum Condition: CodedRepresentable, Equatable, Sendable {
}

private static func heightGroup(_ feet: UInt) -> String {
String(format: "%03d", feet / 100)
unsafe String(format: "%03d", feet / 100)
}

private static func suffix(_ type: CeilingType?) -> String {
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftMETAR/Common/Parsers/CommonParsers.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
func parseLocationID(_ parts: inout [String.SubSequence]) throws -> String {
func parseLocationID(_ parts: inout [String.SubSequence]) throws(Error) -> String {
guard !parts.isEmpty else { throw Error.badFormat }
return String(parts.removeFirst())
}
8 changes: 3 additions & 5 deletions Sources/SwiftMETAR/Common/Parsers/ConditionsParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ final class ConditionsParser: WarmableParser, @unchecked Sendable {
private static let coverageRef = Reference<Coverage>()
private static let heightRef = Reference<UInt>()
private static let ceilingTypeRef = Reference<Condition.CeilingType?>()
// swiftlint:disable force_try
private static let rx = LockedRegex(
Regex {
Anchor.startOfSubject
Capture(as: coverageRef) {
try! Coverage.rx
Coverage.rx
} transform: {
.init(rawValue: String($0))!
}
Expand All @@ -20,16 +19,15 @@ final class ConditionsParser: WarmableParser, @unchecked Sendable {
.init($0)! * 100
}
Capture(as: ceilingTypeRef) {
Optionally(try! Condition.CeilingType.rx)
Optionally(Condition.CeilingType.rx)
} transform: {
.init(rawValue: String($0))
}
Anchor.endOfSubject
}
)
// swiftlint:enable force_try

func parse(_ parts: inout [String.SubSequence]) throws -> [Condition] {
func parse(_ parts: inout [String.SubSequence]) throws(Error) -> [Condition] {
if parts.isEmpty { return [] }

var conditions = [Condition]()
Expand Down
16 changes: 9 additions & 7 deletions Sources/SwiftMETAR/Common/Parsers/DateParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ final class DayHourMinuteParser: WarmableParser, @unchecked Sendable {
_ = try? anchoredRx.wholeMatch(in: "")
}

func parse(_ parts: inout [String.SubSequence], referenceDate: Date? = nil) throws
func parse(_ parts: inout [String.SubSequence], referenceDate: Date? = nil) throws(Error)
-> DateComponents
{
guard !parts.isEmpty else { throw Error.badFormat }
Expand All @@ -48,12 +48,13 @@ final class DayHourMinuteParser: WarmableParser, @unchecked Sendable {
return try parse(match: match, referenceDate: referenceDate, originalString: dateStr)
}

func matchesNext(_ parts: [String.SubSequence]) throws -> Bool {
func matchesNext(_ parts: [String.SubSequence]) throws(Error) -> Bool {
guard let str = parts.first else { return false }
return try anchoredRx.matches(String(str))
}

func parse<T>(match: Regex<T>.Match, referenceDate: Date? = nil, originalString: String) throws
func parse<T>(match: Regex<T>.Match, referenceDate: Date? = nil, originalString: String)
throws(Error)
-> DateComponents
{
var (day, hour, minute) = match[dateRef]
Expand Down Expand Up @@ -109,7 +110,7 @@ class DayHourParser {
referenceDate: Date? = nil,
afterDate afterDateComponents: DateComponents? = nil,
originalString string: String
) throws -> DateComponents {
) throws(Error) -> DateComponents {
var (day, hour) = match[dateRef]
var addDay = false
if hour == 24 {
Expand Down Expand Up @@ -198,7 +199,7 @@ class HourMinutePeriodParser {
match: Regex<T>.Match,
referenceDate: Date? = nil,
originalString: String
) throws -> DateComponentsInterval {
) throws(Error) -> DateComponentsInterval {
let startTime = try minuteOfDay(
hour: match[startHourRef],
minute: match[startMinuteRef],
Expand Down Expand Up @@ -227,7 +228,8 @@ class HourMinutePeriodParser {

/// Converts an `HHMM` time to minutes past midnight, treating `2400` as the end
/// of the day the way ``DayHourMinuteParser`` does.
private func minuteOfDay(hour: UInt8, minute: UInt8, originalString: String) throws -> Int {
private func minuteOfDay(hour: UInt8, minute: UInt8, originalString: String) throws(Error) -> Int
{
guard hour <= 24, minute <= 59, hour < 24 || minute == 0 else {
throw Error.invalidDate(originalString)
}
Expand Down Expand Up @@ -276,7 +278,7 @@ class HourMinuteParser {
}

func parse<T>(match: Regex<T>.Match, referenceDate: Date? = nil, originalString string: String)
throws -> DateComponents
throws(Error) -> DateComponents
{
let (hour, minute) = match[dateRef]

Expand Down
12 changes: 4 additions & 8 deletions Sources/SwiftMETAR/Common/Parsers/VisibilityParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ final class VisibilityParser: WarmableParser, @unchecked Sendable {
_ = try? fractionRx.wholeMatch(in: "")
}

func parse(_ parts: inout [String.SubSequence]) throws -> Visibility? {
func parse(_ parts: inout [String.SubSequence]) throws(Error) -> Visibility? {
guard !parts.isEmpty else { return nil }

if parts.count >= 2 {
Expand All @@ -59,7 +59,7 @@ final class VisibilityParser: WarmableParser, @unchecked Sendable {
return nil
}

private func parse(_ vizStr: String) throws -> Visibility? {
private func parse(_ vizStr: String) throws(Error) -> Visibility? {
if vizStr == "CAVOK" {
return .greaterThan(.meters(9999))
}
Expand Down Expand Up @@ -94,15 +94,13 @@ final class VisibilityParser: WarmableParser, @unchecked Sendable {
class OpenRangeParser {
private static let boundRef = Reference<OpenRange>()

// swiftlint:disable force_try
static let rx = Regex {
Capture(as: boundRef) {
try! OpenRange.rx
OpenRange.rx
} transform: {
.init(rawValue: String($0))!
}
}
// swiftlint:enable force_try

func parse<T>(_ match: Regex<T>.Match) -> OpenRange {
match[Self.boundRef]
Expand All @@ -124,7 +122,6 @@ final class VisibilityParser: WarmableParser, @unchecked Sendable {
private static let valueRef = Reference<UInt16>()
private static let unitRef = Reference<VisibilityDistanceUnit>()

// swiftlint:disable force_try
static let rx = Regex {
OpenRangeParser.rx
Capture(as: valueRef) {
Expand All @@ -133,12 +130,11 @@ final class VisibilityParser: WarmableParser, @unchecked Sendable {
.init($0)!
}
Capture(as: unitRef) {
try! Optionally(VisibilityDistanceUnit.rx)
Optionally(VisibilityDistanceUnit.rx)
} transform: {
.from(raw: String($0))!
}
}
// swiftlint:enable force_try

private let openRangeParser = OpenRangeParser()

Expand Down
19 changes: 8 additions & 11 deletions Sources/SwiftMETAR/Common/Parsers/WeatherParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,21 @@ final class WeatherParser: WarmableParser, @unchecked Sendable {
private static let descriptorRef = Reference<Weather.Descriptor?>()
private static let phenomenaRef = Reference<Substring>()

// swiftlint:disable force_try
private static let weatherRx = LockedRegex(
Regex {
Anchor.startOfSubject
Capture(as: intensityRef) {
try! Weather.Intensity.rx
Weather.Intensity.rx
} transform: {
.init(rawValue: String($0))!
}
Capture(as: descriptorRef) {
Optionally { try! Weather.Descriptor.rx }
Optionally { Weather.Descriptor.rx }
} transform: {
.init(rawValue: String($0))
}
Capture(as: phenomenaRef) {
OneOrMore { try! Weather.Phenomenon.rx }
OneOrMore { Weather.Phenomenon.rx }
}
Anchor.endOfSubject
}
Expand All @@ -33,9 +32,8 @@ final class WeatherParser: WarmableParser, @unchecked Sendable {
Anchor.endOfSubject
}
)
// swiftlint:enable force_try

func parse(_ parts: inout [String.SubSequence]) throws -> [Weather]? {
func parse(_ parts: inout [String.SubSequence]) throws(Error) -> [Weather]? {
var weather = [Weather]()

while true {
Expand All @@ -62,17 +60,16 @@ final class WeatherParser: WarmableParser, @unchecked Sendable {
let intensity = match[Self.intensityRef]
let descriptor = match[Self.descriptorRef]

let phenomenaStr = match[Self.phenomenaRef]
let phenomenaStrs = String(phenomenaStr).partition(by: 2)
let phenomena = try phenomenaStrs.map { code -> Weather.Phenomenon in
var phenomena = Set<Weather.Phenomenon>()
for code in String(match[Self.phenomenaRef]).partition(by: 2) {
guard let phenomenon = Weather.Phenomenon(rawValue: code) else {
throw Error.invalidWeather(weatherStr)
}
return phenomenon
phenomena.insert(phenomenon)
}

weather.append(
Weather(intensity: intensity, descriptor: descriptor, phenomena: Set(phenomena))
Weather(intensity: intensity, descriptor: descriptor, phenomena: phenomena)
)
} else {
return weather
Expand Down
6 changes: 3 additions & 3 deletions Sources/SwiftMETAR/Common/Parsers/WindParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ enum WindParser {
}
)

static func parse(_ parts: inout [String.SubSequence]) throws -> Wind? {
static func parse(_ parts: inout [String.SubSequence]) throws(Error) -> Wind? {
guard !parts.isEmpty else { return nil }
let dirAndSpeed = String(parts[0])

Expand Down Expand Up @@ -122,7 +122,7 @@ enum WindParser {
}
}

static func parse<T>(match: Regex<T>.Match, originalString: String) throws -> Wind {
static func parse<T>(match: Regex<T>.Match, originalString: String) throws(Error) -> Wind {
let speedValue = match[speedRef]
let speed: Wind.Speed =
switch match[unitRef] {
Expand Down Expand Up @@ -155,7 +155,7 @@ enum WindParser {
private static func parseDirectionRange(
_ parts: inout [String.SubSequence],
rangeSeq: String.SubSequence
) throws -> (UInt16, UInt16)? {
) throws(Error) -> (UInt16, UInt16)? {
let rangeStr = String(rangeSeq)

guard let variableMatch = try variableRx.wholeMatch(in: rangeStr),
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftMETAR/Common/Parsers/WindshearParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ final class WindshearParser: WarmableParser, @unchecked Sendable {
}
)

func parse(_ parts: inout [String.SubSequence]) throws -> Windshear? {
func parse(_ parts: inout [String.SubSequence]) throws(Error) -> Windshear? {
guard !parts.isEmpty else { return nil }
let windshearStr = String(parts[0])
guard let result = try Self.rx.wholeMatch(in: windshearStr) else { return nil }
Expand Down
4 changes: 2 additions & 2 deletions Sources/SwiftMETAR/Common/Visibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public enum Visibility: CodedRepresentable, Equatable, Sendable {
- Throws: ``Error/invalidVisibility(_:)`` if `coded` is not a valid
visibility group.
*/
public init(coded: String) throws {
public init(coded: String) throws(Error) {
var parts = coded.split(whereSeparator: \.isWhitespace)
if let visibility = try VisibilityParser().parse(&parts), parts.isEmpty {
self = visibility
Expand Down Expand Up @@ -172,7 +172,7 @@ public enum Visibility: CodedRepresentable, Equatable, Sendable {
- Throws: ``Error/invalidVisibility(_:)`` if `coded` is not a valid,
unqualified distance.
*/
public init(coded: String) throws {
public init(coded: String) throws(Error) {
guard case let .equal(value) = try Visibility(coded: coded) else {
throw Error.invalidVisibility(coded)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftMETAR/Common/Weather.swift
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ extension Weather: CodedRepresentable {
- Throws: ``Error/invalidWeather(_:)`` if `coded` is not a single valid
weather group.
*/
public init(coded: String) throws {
public init(coded: String) throws(Error) {
var parts = coded.split(whereSeparator: \.isWhitespace)
guard let weather = try WeatherParser().parse(&parts),
weather.count == 1, parts.isEmpty
Expand Down
Loading
Loading