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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 2 additions & 3 deletions Sources/SwiftNASR/Models/Records/Airport/Airport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ 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 stable across cycles.
public let id: String

/// The airport name.
Expand Down
17 changes: 17 additions & 0 deletions Sources/SwiftNASR/Parsers/ByteParsing/ByteTransformer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions Sources/SwiftNASR/Parsers/FixedWidthParser/FixedWidthParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -98,6 +99,19 @@ enum FixedWidthParserError: Swift.Error, CustomStringConvertible, Sendable {
localized:
"Field #\(field) type mismatch: expected \(String(describing: expected)), got \(String(describing: actual))"
)
case let .fieldCountMismatch(expected, actual):
// `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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<UInt8>]) throws {
guard let airportIndex = values[1].toTrimmedString() else { return }
guard let airport = airports[airportIndex] else { return }
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
)
}
}
46 changes: 46 additions & 0 deletions Tests/SwiftNASRTests/Parsers/FixedWidthParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading