From 8561229486df1338e89c002adf91cc3f84ae2991 Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Tue, 8 Sep 2026 13:58:25 -0700 Subject: [PATCH 1/3] Report parser field mismatches instead of trapping Two uncatchable traps in the fixed-width path: - parsePavementClassification indexed the five components of a PCN value without checking how many the split produced. The Pavement Classification field is widening from 11 to 16 characters for the ICAO PCR transition, and a PCR value is conventionally four-part, so a value from an upcoming cycle would trap on the first runway record carrying one. The call site wraps the parse in do/catch, but an out-of-range subscript is a trap, not a throw, so that handler never ran. - ByteTransformer indexed its compiled-in transformation list by the position of a slice produced from the runtime-parsed layout. A layout that gains a field trapped; one that loses a field silently read every subsequent value from its neighbour. Both now throw, so the parse error handler sees them and the affected record is dropped rather than the process. Airport.id is documented as unique within a single cycle rather than stable across cycles: the FAA re-keyed FAA LID 18AL from site number 03329.19 to 00329.19 in the 2026-09-03 cycle. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QZ3UWydnavm3g9Gs62Xirb --- CHANGELOG.md | 11 ++++ .../Models/Records/Airport/Airport.swift | 10 +++- .../Parsers/ByteParsing/ByteTransformer.swift | 17 ++++++ .../FixedWidthParser/FixedWidthParser.swift | 5 ++ .../AirportParser/AirportParser+Runway.swift | 58 +++++++++++-------- .../Parsers/FixedWidthParserTests.swift | 46 +++++++++++++++ 6 files changed, 119 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e3af4..17b6256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Change Log +## [Unreleased] + +### Fixed + +- A runway's Pavement Classification field no longer crashes the fixed-width airport parser when it does not hold a five-part PCN value. The field is widening from 11 to 16 characters for the ICAO PCR transition, and a PCR value is conventionally four-part rather than PCN's `number/type/subgrade/tirePressure/determination`; splitting such a value and indexing the missing components trapped on an out-of-range index, which the parser's own `do`/`catch` could not intercept. Any value that does not yield exactly five components is now reported as an `invalidValue` field error, so the record is diagnosed and skipped instead of killing the process +- A layout whose field count disagrees with a parser's compiled-in field transformations is now reported as an error rather than trapping (a layout with an extra field) or silently reading each subsequent value from its neighbouring field (a layout with one fewer) + +### Changed + +- `Airport.id` is documented as unique within a single NASR cycle rather than stable across cycles. The FAA re-keyed FAA LID `18AL` (LOUISVILLE STAGEFIELD AHP) from site number `03329.19` to `00329.19` in the 2026-09-03 cycle, so persisting either the site number or the LID across cycles requires a reconciliation step + ## [4.1.1] - 2026-09-02 ### Fixed diff --git a/Sources/SwiftNASR/Models/Records/Airport/Airport.swift b/Sources/SwiftNASR/Models/Records/Airport/Airport.swift index a08527a..3aaa28d 100644 --- a/Sources/SwiftNASR/Models/Records/Airport/Airport.swift +++ b/Sources/SwiftNASR/Models/Records/Airport/Airport.swift @@ -16,9 +16,13 @@ public struct Airport: ParentRecord { // MARK: - Properties - /// A unique identifier for this airport. This field should be used to - /// uniquely identify an airport, as the ``LID`` for an airport - /// can sometimes change. + /** + The FAA site number, which identifies this airport uniquely within a single + NASR cycle. It is not a stable identifier across cycles: the FAA + occasionally corrects a site number, just as an airport's ``LID`` can + change. Persisting either value across cycles requires a reconciliation step + to detect a record that has been re-keyed rather than retired. + */ public let id: String /// The airport name. diff --git a/Sources/SwiftNASR/Parsers/ByteParsing/ByteTransformer.swift b/Sources/SwiftNASR/Parsers/ByteParsing/ByteTransformer.swift index 7b90e0c..ad71401 100644 --- a/Sources/SwiftNASR/Parsers/ByteParsing/ByteTransformer.swift +++ b/Sources/SwiftNASR/Parsers/ByteParsing/ByteTransformer.swift @@ -23,7 +23,24 @@ struct ByteTransformer { } /// Applies the field transformations to byte slices. + /// + /// The slices come from the layout file shipped with the distribution, while + /// the transformations are compiled in, so a layout that gains or loses a + /// field no longer lines up with them. + /// + /// - Parameter slices: One slice per field, in layout order. + /// - Returns: The transformed row. + /// - Throws: ``FixedWidthParserError/fieldCountMismatch(expected:actual:)`` + /// if the layout and the transformations describe different numbers + /// of fields. func applyTo(_ slices: [ByteSlice]) throws -> FixedWidthTransformedRow { + guard slices.count == fields.count else { + throw FixedWidthParserError.fieldCountMismatch( + expected: fields.count, + actual: slices.count + ) + } + let transformedValues = try slices.enumerated().map { index, slice -> Any? in switch fields[index] { case .recordType: diff --git a/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthParser.swift b/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthParser.swift index baa4924..dcd009a 100644 --- a/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthParser.swift +++ b/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthParser.swift @@ -74,6 +74,7 @@ enum FixedWidthParserError: Swift.Error, CustomStringConvertible, Sendable { case conversionError(_ value: String, error: any Swift.Error, at: Int) case invalidValue(_ value: String, at: Int) case typeMismatch(at: Int, expected: Any.Type, actual: Any.Type) + case fieldCountMismatch(expected: Int, actual: Int) var description: String { switch self { @@ -98,6 +99,10 @@ enum FixedWidthParserError: Swift.Error, CustomStringConvertible, Sendable { localized: "Field #\(field) type mismatch: expected \(String(describing: expected)), got \(String(describing: actual))" ) + case let .fieldCountMismatch(expected, actual): + return String( + localized: "Layout describes \(actual) fields, but the parser transforms \(expected)" + ) } } } diff --git a/Sources/SwiftNASR/Parsers/Record Parsers/AirportParser/AirportParser+Runway.swift b/Sources/SwiftNASR/Parsers/Record Parsers/AirportParser/AirportParser+Runway.swift index 5df2d9f..09f6439 100644 --- a/Sources/SwiftNASR/Parsers/Record Parsers/AirportParser/AirportParser+Runway.swift +++ b/Sources/SwiftNASR/Parsers/Record Parsers/AirportParser/AirportParser+Runway.swift @@ -3,6 +3,11 @@ import Foundation private let offsetParser = OffsetParser() extension FixedWidthAirportParser { + + /// The number of slash-separated components in a PCN value: + /// `number/type/subgradeStrength/tirePressure/determinationMethod`. + private static let pavementClassificationComponentCount = 5 + private var runwayTransformer: ByteTransformer { .init([ .recordType, // 0 record type @@ -213,6 +218,33 @@ extension FixedWidthAirportParser { ]) } + static func parsePavementClassification(_ value: String) throws -> Runway.PavementClassification { + let components = value.split(separator: "/") + guard components.count == Self.pavementClassificationComponentCount else { + throw Error.invalidPavementClassification(value) + } + let numberStr = String(components[0]).trimmingCharacters(in: .whitespaces) + guard let number = UInt(numberStr) else { throw Error.invalidPavementClassification(value) } + let type = try Runway.PavementClassification.Classification.require(String(components[1])) + let strength = try Runway.PavementClassification.SubgradeStrengthCategory.require( + String(components[2]) + ) + let tirePressure = try Runway.PavementClassification.TirePressureLimit.require( + String(components[3]) + ) + let determination = try Runway.PavementClassification.DeterminationMethod.require( + String(components[4]) + ) + + return Runway.PavementClassification( + number: number, + type: type, + subgradeStrengthCategory: strength, + tirePressureLimit: tirePressure, + determinationMethod: determination + ) + } + func parseRunwayRecord(_ values: [ArraySlice]) throws { guard let airportIndex = values[1].toTrimmedString() else { return } guard let airport = airports[airportIndex] else { return } @@ -233,7 +265,7 @@ extension FixedWidthAirportParser { let pavementClassification: Runway.PavementClassification? if let classStr: String = try t[optional: 8] { do { - pavementClassification = try parsePavementClassification(classStr) + pavementClassification = try Self.parsePavementClassification(classStr) } catch { throw FixedWidthParserError.invalidValue(classStr, at: 8) } @@ -298,28 +330,4 @@ extension FixedWidthAirportParser { return (materials, condition) } - - private func parsePavementClassification(_ value: String) throws -> Runway.PavementClassification { - let components = value.split(separator: "/") - let numberStr = String(components[0]).trimmingCharacters(in: .whitespaces) - guard let number = UInt(numberStr) else { throw Error.invalidPavementClassification(value) } - let type = try Runway.PavementClassification.Classification.require(String(components[1])) - let strength = try Runway.PavementClassification.SubgradeStrengthCategory.require( - String(components[2]) - ) - let tirePressure = try Runway.PavementClassification.TirePressureLimit.require( - String(components[3]) - ) - let determination = try Runway.PavementClassification.DeterminationMethod.require( - String(components[4]) - ) - - return Runway.PavementClassification( - number: number, - type: type, - subgradeStrengthCategory: strength, - tirePressureLimit: tirePressure, - determinationMethod: determination - ) - } } diff --git a/Tests/SwiftNASRTests/Parsers/FixedWidthParserTests.swift b/Tests/SwiftNASRTests/Parsers/FixedWidthParserTests.swift index 7baddb9..76ab04b 100644 --- a/Tests/SwiftNASRTests/Parsers/FixedWidthParserTests.swift +++ b/Tests/SwiftNASRTests/Parsers/FixedWidthParserTests.swift @@ -122,4 +122,50 @@ struct FixedWidthParserTests { let values = try #require(await parser.parsedValues) #expect(values.compactMap { $0.toString() } == ["RWY", "ALPHA", "BETA"]) } + + // MARK: layout and transformer field counts + + @Test(arguments: [1, 3]) + func `reports a field count mismatch when the layout and transformer disagree`( + _ sliceCount: Int + ) throws { + let transformer = ByteTransformer([.recordType, .string()]) + let slices = [ByteSlice](repeating: Array("X".utf8)[...], count: sliceCount) + + #expect { + try transformer.applyTo(slices) + } throws: { error in + guard case let FixedWidthParserError.fieldCountMismatch(expected, actual) = error else { + return false + } + return expected == 2 && actual == sliceCount + } + } + + // MARK: pavement classification + + @Test(arguments: ["61", "560/R/B/W", "61//B/X/T", "61/R/B/X/T/U"]) + func `reports an invalid pavement classification for a value without five components`( + _ value: String + ) throws { + #expect { + try FixedWidthAirportParser.parsePavementClassification(value) + } throws: { error in + guard case let SwiftNASR.Error.invalidPavementClassification(reported) = error else { + return false + } + return reported == value + } + } + + @Test + func `parses a five component pavement classification`() throws { + let classification = try FixedWidthAirportParser.parsePavementClassification("61/R/B/X/T") + + #expect(classification.number == 61) + #expect(classification.type == .rigid) + #expect(classification.subgradeStrengthCategory == .medium) + #expect(classification.tirePressureLimit == .high) + #expect(classification.determinationMethod == .technical) + } } From da88cf4043bebc23ce107ace00c021fe40fd51ae Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Tue, 8 Sep 2026 14:23:37 -0700 Subject: [PATCH 2/3] Scope the site number docs and number-format the field counts The site number's documentation covers what the identifier is and the cycle it is unique within; guidance on handling cycle updates belongs elsewhere. The field count mismatch message renders both counts through `.number` so they localize. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QZ3UWydnavm3g9Gs62Xirb --- Sources/SwiftNASR/Models/Records/Airport/Airport.swift | 9 ++------- .../Parsers/FixedWidthParser/FixedWidthParser.swift | 3 ++- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftNASR/Models/Records/Airport/Airport.swift b/Sources/SwiftNASR/Models/Records/Airport/Airport.swift index 3aaa28d..797431c 100644 --- a/Sources/SwiftNASR/Models/Records/Airport/Airport.swift +++ b/Sources/SwiftNASR/Models/Records/Airport/Airport.swift @@ -16,13 +16,8 @@ public struct Airport: ParentRecord { // MARK: - Properties - /** - The FAA site number, which identifies this airport uniquely within a single - NASR cycle. It is not a stable identifier across cycles: the FAA - occasionally corrects a site number, just as an airport's ``LID`` can - change. Persisting either value across cycles requires a reconciliation step - to detect a record that has been re-keyed rather than retired. - */ + /// The FAA site number, which identifies this airport uniquely within a single + /// NASR cycle. It is not stable across cycles. public let id: String /// The airport name. diff --git a/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthParser.swift b/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthParser.swift index dcd009a..d59bc15 100644 --- a/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthParser.swift +++ b/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthParser.swift @@ -101,7 +101,8 @@ enum FixedWidthParserError: Swift.Error, CustomStringConvertible, Sendable { ) case let .fieldCountMismatch(expected, actual): return String( - localized: "Layout describes \(actual) fields, but the parser transforms \(expected)" + localized: + "Layout describes \(actual, format: .number) fields, but the parser transforms \(expected, format: .number)" ) } } From d3816a4ee13595df2a027f0ba182d24a3bf50b5d Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Tue, 8 Sep 2026 14:29:51 -0700 Subject: [PATCH 3/3] Format the field counts only where the platform supports it Foundation on Linux resolves `String(localized:)` through a plain-`String` initializer, whose interpolation takes no format style. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QZ3UWydnavm3g9Gs62Xirb --- .../FixedWidthParser/FixedWidthParser.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthParser.swift b/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthParser.swift index d59bc15..2fa471a 100644 --- a/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthParser.swift +++ b/Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthParser.swift @@ -100,10 +100,18 @@ enum FixedWidthParserError: Swift.Error, CustomStringConvertible, Sendable { "Field #\(field) type mismatch: expected \(String(describing: expected)), got \(String(describing: actual))" ) case let .fieldCountMismatch(expected, actual): - return String( - localized: - "Layout describes \(actual, format: .number) fields, but the parser transforms \(expected, format: .number)" - ) + // `String.LocalizationValue` interpolation takes a format style; the Linux shim's + // plain-`String` initializer does not. + #if canImport(Darwin) + return String( + localized: + "Layout describes \(actual, format: .number) fields, but the parser transforms \(expected, format: .number)" + ) + #else + return String( + localized: "Layout describes \(actual) fields, but the parser transforms \(expected)" + ) + #endif } } }