Skip to content

Commit 4c5f9a4

Browse files
authored
Introduce a new ABI.VersionNumber type. (#1239)
This PR introduces and plumbs through an internal `ABI.VersionNumber` type that represents the JSON event stream version that should be used during a test run. This type supports integers (such as the current version 0) and semantic versions (e.g. 6.3 or 6.3.0). Care is taken to ensure backwards compatibility for ABI versions -1 and 0 which do not accept a string in this position. The new type necessarily conforms to `Comparable` and `Codable`. I've adjusted `__CommandLineArguments_v0` to support this new type while maintaining backwards-compatibility with Xcode 16. For the moment, these are all implementation details that have no impact on our supported interface as there is no newer JSON schema than 0. We intend to make changes to the schema in the next Swift release (assumed to be 6.3) that will require a Swift Evolution review. ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated.
1 parent b12203f commit 4c5f9a4

File tree

11 files changed

+342
-68
lines changed

11 files changed

+342
-68
lines changed

Sources/Testing/ABI/ABI.Record.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ extension ABI.Record: Codable {
6666
init(from decoder: any Decoder) throws {
6767
let container = try decoder.container(keyedBy: CodingKeys.self)
6868

69-
let versionNumber = try container.decode(Int.self, forKey: .version)
69+
let versionNumber = try container.decode(ABI.VersionNumber.self, forKey: .version)
7070
if versionNumber != V.versionNumber {
7171
throw DecodingError.dataCorrupted(
7272
DecodingError.Context(
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024–2025 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
extension ABI {
12+
/// A type describing an ABI version number.
13+
///
14+
/// This type implements a subset of the [semantic versioning](https://semver.org)
15+
/// specification (specifically parsing, displaying, and comparing
16+
/// `<version core>` values we expect that Swift will need for the foreseeable
17+
/// future.)
18+
struct VersionNumber: Sendable {
19+
/// The major version.
20+
var majorComponent: Int8
21+
22+
/// The minor version.
23+
var minorComponent: Int8
24+
25+
/// The patch, revision, or bug fix version.
26+
var patchComponent: Int8 = 0
27+
}
28+
}
29+
30+
extension ABI.VersionNumber {
31+
init(_ majorComponent: _const Int8, _ minorComponent: _const Int8, _ patchComponent: _const Int8 = 0) {
32+
self.init(majorComponent: majorComponent, minorComponent: minorComponent, patchComponent: patchComponent)
33+
}
34+
}
35+
36+
// MARK: - CustomStringConvertible
37+
38+
extension ABI.VersionNumber: CustomStringConvertible {
39+
/// Initialize an instance of this type by parsing the given string.
40+
///
41+
/// - Parameters:
42+
/// - string: The string to parse, such as `"0"` or `"6.3.0"`.
43+
///
44+
/// @Comment {
45+
/// - Bug: We are not able to reuse the logic from swift-syntax's
46+
/// `VersionTupleSyntax` type here because we cannot link to swift-syntax
47+
/// in this target.
48+
/// }
49+
///
50+
/// If `string` contains fewer than 3 numeric components, the missing
51+
/// components are inferred to be `0` (for example, `"1.2"` is equivalent to
52+
/// `"1.2.0"`.) If `string` contains more than 3 numeric components, the
53+
/// additional components are ignored.
54+
init?(_ string: String) {
55+
// Split the string on "." (assuming it is of the form "1", "1.2", or
56+
// "1.2.3") and parse the individual components as integers.
57+
let components = string.split(separator: ".", omittingEmptySubsequences: false)
58+
func componentValue(_ index: Int) -> Int8? {
59+
components.count > index ? Int8(components[index]) : 0
60+
}
61+
62+
guard let majorComponent = componentValue(0),
63+
let minorComponent = componentValue(1),
64+
let patchComponent = componentValue(2) else {
65+
return nil
66+
}
67+
self.init(majorComponent: majorComponent, minorComponent: minorComponent, patchComponent: patchComponent)
68+
}
69+
70+
var description: String {
71+
if majorComponent <= 0 && minorComponent == 0 && patchComponent == 0 {
72+
// Version 0 and earlier are described as integers for compatibility with
73+
// Swift 6.2 and earlier.
74+
return String(describing: majorComponent)
75+
} else if patchComponent == 0 {
76+
return "\(majorComponent).\(minorComponent)"
77+
}
78+
return "\(majorComponent).\(minorComponent).\(patchComponent)"
79+
}
80+
}
81+
82+
// MARK: - Equatable, Comparable
83+
84+
extension ABI.VersionNumber: Equatable, Comparable {
85+
static func <(lhs: Self, rhs: Self) -> Bool {
86+
if lhs.majorComponent != rhs.majorComponent {
87+
return lhs.majorComponent < rhs.majorComponent
88+
} else if lhs.minorComponent != rhs.minorComponent {
89+
return lhs.minorComponent < rhs.minorComponent
90+
} else if lhs.patchComponent != rhs.patchComponent {
91+
return lhs.patchComponent < rhs.patchComponent
92+
}
93+
return false
94+
}
95+
}
96+
97+
// MARK: - Codable
98+
99+
extension ABI.VersionNumber: Codable {
100+
init(from decoder: any Decoder) throws {
101+
let container = try decoder.singleValueContainer()
102+
if let number = try? container.decode(Int8.self) {
103+
// Allow for version numbers encoded as integers for compatibility with
104+
// Swift 6.2 and earlier.
105+
self.init(majorComponent: number, minorComponent: 0)
106+
} else {
107+
let string = try container.decode(String.self)
108+
guard let result = Self(string) else {
109+
throw DecodingError.dataCorrupted(
110+
.init(
111+
codingPath: decoder.codingPath,
112+
debugDescription: "Unexpected string '\(string)' (expected an integer or a string of the form '1.2.3')"
113+
)
114+
)
115+
}
116+
self = result
117+
}
118+
}
119+
120+
func encode(to encoder: any Encoder) throws {
121+
var container = encoder.singleValueContainer()
122+
if majorComponent <= 0 && minorComponent == 0 && patchComponent == 0 {
123+
// Version 0 and earlier are encoded as integers for compatibility with
124+
// Swift 6.2 and earlier.
125+
try container.encode(majorComponent)
126+
} else {
127+
try container.encode("\(majorComponent).\(minorComponent).\(patchComponent)")
128+
}
129+
}
130+
}

Sources/Testing/ABI/ABI.swift

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ extension ABI {
1818
/// A protocol describing the types that represent different ABI versions.
1919
protocol Version: Sendable {
2020
/// The numeric representation of this ABI version.
21-
static var versionNumber: Int { get }
21+
static var versionNumber: VersionNumber { get }
2222

2323
#if canImport(Foundation) && (!SWT_NO_FILE_IO || !SWT_NO_ABI_ENTRY_POINT)
2424
/// Create an event handler that encodes events as JSON and forwards them to
@@ -44,6 +44,33 @@ extension ABI {
4444

4545
/// The current supported ABI version (ignoring any experimental versions.)
4646
typealias CurrentVersion = v0
47+
48+
#if !hasFeature(Embedded)
49+
/// Get the type representing a given ABI version.
50+
///
51+
/// - Parameters:
52+
/// - versionNumber: The ABI version number for which a concrete type is
53+
/// needed.
54+
///
55+
/// - Returns: A type conforming to ``ABI/Version`` that represents the given
56+
/// ABI version, or `nil` if no such type exists.
57+
static func version(forVersionNumber versionNumber: VersionNumber = ABI.CurrentVersion.versionNumber) -> (any Version.Type)? {
58+
switch versionNumber {
59+
case ABI.v6_3.versionNumber...:
60+
ABI.v6_3.self
61+
case ABI.v0.versionNumber...:
62+
ABI.v0.self
63+
#if !SWT_NO_SNAPSHOT_TYPES
64+
case ABI.Xcode16.versionNumber:
65+
// Legacy support for Xcode 16. Support for this undocumented version will
66+
// be removed in a future update. Do not use it.
67+
ABI.Xcode16.self
68+
#endif
69+
default:
70+
nil
71+
}
72+
}
73+
#endif
4774
}
4875

4976
// MARK: - Concrete ABI versions
@@ -54,28 +81,28 @@ extension ABI {
5481
///
5582
/// - Warning: This type will be removed in a future update.
5683
enum Xcode16: Sendable, Version {
57-
static var versionNumber: Int {
58-
-1
84+
static var versionNumber: VersionNumber {
85+
VersionNumber(-1, 0)
5986
}
6087
}
6188
#endif
6289

6390
/// A namespace and type for ABI version 0 symbols.
6491
public enum v0: Sendable, Version {
65-
static var versionNumber: Int {
66-
0
92+
static var versionNumber: VersionNumber {
93+
VersionNumber(0, 0)
6794
}
6895
}
6996

70-
/// A namespace and type for ABI version 1 symbols.
97+
/// A namespace and type for ABI version 6.3 symbols.
7198
///
7299
/// @Metadata {
73-
/// @Available("Swift Testing ABI", introduced: 1)
100+
/// @Available(Swift, introduced: 6.3)
74101
/// }
75102
@_spi(Experimental)
76-
public enum v1: Sendable, Version {
77-
static var versionNumber: Int {
78-
1
103+
public enum v6_3: Sendable, Version {
104+
static var versionNumber: VersionNumber {
105+
VersionNumber(6, 3)
79106
}
80107
}
81108
}

Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ extension ABI {
9898
sourceLocation = test.sourceLocation
9999
id = ID(encoding: test.id)
100100

101-
if V.versionNumber >= ABI.v1.versionNumber {
101+
if V.versionNumber >= ABI.v6_3.versionNumber {
102102
let tags = test.tags
103103
if !tags.isEmpty {
104104
_tags = tags.map(String.init(describing:))

Sources/Testing/ABI/EntryPoints/ABIEntryPoint.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ extension ABI.v0 {
5050
let args = try configurationJSON.map { configurationJSON in
5151
try JSON.decode(__CommandLineArguments_v0.self, from: configurationJSON)
5252
}
53-
let eventHandler = try eventHandlerForStreamingEvents(version: args?.eventStreamVersion, encodeAsJSONLines: false, forwardingTo: recordHandler)
53+
let eventHandler = try eventHandlerForStreamingEvents(withVersionNumber: args?.eventStreamVersionNumber, encodeAsJSONLines: false, forwardingTo: recordHandler)
5454

5555
switch await Testing.entryPoint(passing: args, eventHandler: eventHandler) {
5656
case EXIT_SUCCESS, EXIT_NO_TESTS_FOUND:

0 commit comments

Comments
 (0)