Skip to content

Commit 62c3458

Browse files
[PackageDescription] correct semantic version parsing and comparison (#3486)
* [gardening] rename `PackageDescription4Tests` to `PackageDescriptionTests` * assign directly to `self` the parsed version, instead of calling `init(_ version: Version)` `init(_ version: Version)` is removed because it doesn’t have any caller left. * correct semantic version parsing The semantic versioning specification 2.0.0 [states](https://semver.org/#spec-item-9) that pre-release identifiers must be positioned after the version core, and build metadata identifiers after pre-release identifiers. In the old implementation, if a version core was appended with metadata identifiers that contain hyphens ("-"), the first hyphen would be mistaken as an indication of pre-release identifiers thereafter. Then, the position of the first hyphen would be treated as where the version core ends, resulting in a false negative after it was found that the "version core" contained non-numeric characters. For example: the semantic version `1.2.3+some-meta.data` is a well-formed, with `1.2.3` being the version core and `some-meta.data` the metadata identifiers. However, the old implementation of `Version.init?(_ versionString: String)` would falsely treat `1.2.3+some` as the version core and `meta.data` the pre-release identifiers. The new implementation fixes this problem by restricting the search area for "-" to the substring before the first "+". In addition, the logic for breaking up the version core into numeric identifiers has been rewritten to be more understandable. * add tests for all sorts of `Version.init` * correct semantic version comparison `Comparable` does not provide a default implementation for `==`, so the compiler synthesises one composed of [member-wise comparisons](https://github.com/apple/swift-evolution/blob/main/proposals/0185-synthesize-equatable-hashable.md#implementation-details). This leads to a false `false` when 2 semantic versions differ by only their build metadata identifiers, contradicting to SemVer 2.0.0's [comparison rules](https://semver.org/#spec-item-10). * conform `Version` to `LosslessStringConvertible` It already satisfies the requirement (`init?(_ versionString: String)`). * [gardening] improve markdown formatting in documentation comments * remove outdated test function `VersionTests.testBasics` has been replaced by much more thorough test cases in the same file.
1 parent 85c9e09 commit 62c3458

File tree

5 files changed

+583
-83
lines changed

5 files changed

+583
-83
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ let package = Package(
270270
name: "FunctionalPerformanceTests",
271271
dependencies: ["swift-build", "swift-package", "swift-test", "SPMTestSupport"]),
272272
.testTarget(
273-
name: "PackageDescription4Tests",
273+
name: "PackageDescriptionTests",
274274
dependencies: ["PackageDescription"]),
275275
.testTarget(
276276
name: "SPMBuildCoreTests",
Lines changed: 45 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2018 Apple Inc. and the Swift project authors
4+
Copyright (c) 2018 - 2021 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See http://swift.org/LICENSE.txt for license information
88
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
99
*/
1010

1111
extension Version: ExpressibleByStringLiteral {
12-
1312
/// Initializes a version struct with the provided string literal.
14-
///
15-
/// - Parameters:
16-
/// - version: A string literal to use for creating a new version struct.
13+
/// - Parameter version: A string literal to use for creating a new version struct.
1714
public init(stringLiteral value: String) {
1815
if let version = Version(value) {
19-
self.init(version)
16+
self = version
2017
} else {
2118
// If version can't be initialized using the string literal, report
2219
// the error and initialize with a dummy value. This is done to
@@ -44,51 +41,50 @@ extension Version: ExpressibleByStringLiteral {
4441
}
4542
}
4643

47-
extension Version {
48-
49-
/// Initializes a version struct with the provided version.
50-
///
51-
/// - Parameters:
52-
/// - version: A version object to use for creating a new version struct.
53-
public init(_ version: Version) {
54-
major = version.major
55-
minor = version.minor
56-
patch = version.patch
57-
prereleaseIdentifiers = version.prereleaseIdentifiers
58-
buildMetadataIdentifiers = version.buildMetadataIdentifiers
59-
}
60-
44+
extension Version: LosslessStringConvertible {
6145
/// Initializes a version struct with the provided version string.
62-
///
63-
/// - Parameters:
64-
/// - version: A version string to use for creating a new version struct.
46+
/// - Parameter version: A version string to use for creating a new version struct.
6547
public init?(_ versionString: String) {
66-
let prereleaseStartIndex = versionString.firstIndex(of: "-")
67-
let metadataStartIndex = versionString.firstIndex(of: "+")
68-
69-
let requiredEndIndex = prereleaseStartIndex ?? metadataStartIndex ?? versionString.endIndex
70-
let requiredCharacters = versionString.prefix(upTo: requiredEndIndex)
71-
let requiredComponents = requiredCharacters
72-
.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
73-
.map(String.init)
74-
.compactMap({ Int($0) })
75-
.filter({ $0 >= 0 })
76-
77-
guard requiredComponents.count == 3 else { return nil }
78-
79-
self.major = requiredComponents[0]
80-
self.minor = requiredComponents[1]
81-
self.patch = requiredComponents[2]
82-
83-
func identifiers(start: String.Index?, end: String.Index) -> [String] {
84-
guard let start = start else { return [] }
85-
let identifiers = versionString[versionString.index(after: start)..<end]
86-
return identifiers.split(separator: ".").map(String.init)
48+
// SemVer 2.0.0 allows only ASCII alphanumerical characters and "-" in the version string, except for "." and "+" as delimiters. ("-" is used as a delimiter between the version core and pre-release identifiers, but it's allowed within pre-release and metadata identifiers as well.)
49+
// Alphanumerics check will come later, after each identifier is split out (i.e. after the delimiters are removed).
50+
guard versionString.allSatisfy(\.isASCII) else { return nil }
51+
52+
let metadataDelimiterIndex = versionString.firstIndex(of: "+")
53+
// SemVer 2.0.0 requires that pre-release identifiers come before build metadata identifiers
54+
let prereleaseDelimiterIndex = versionString[..<(metadataDelimiterIndex ?? versionString.endIndex)].firstIndex(of: "-")
55+
56+
let versionCore = versionString[..<(prereleaseDelimiterIndex ?? metadataDelimiterIndex ?? versionString.endIndex)]
57+
let versionCoreIdentifiers = versionCore.split(separator: ".", omittingEmptySubsequences: false)
58+
59+
guard
60+
versionCoreIdentifiers.count == 3,
61+
// Major, minor, and patch versions must be ASCII numbers, according to the semantic versioning standard.
62+
// Converting each identifier from a substring to an integer doubles as checking if the identifiers have non-numeric characters.
63+
let major = Int(versionCoreIdentifiers[0]),
64+
let minor = Int(versionCoreIdentifiers[1]),
65+
let patch = Int(versionCoreIdentifiers[2])
66+
else { return nil }
67+
68+
self.major = major
69+
self.minor = minor
70+
self.patch = patch
71+
72+
if let prereleaseDelimiterIndex = prereleaseDelimiterIndex {
73+
let prereleaseStartIndex = versionString.index(after: prereleaseDelimiterIndex)
74+
let prereleaseIdentifiers = versionString[prereleaseStartIndex..<(metadataDelimiterIndex ?? versionString.endIndex)].split(separator: ".", omittingEmptySubsequences: false)
75+
guard prereleaseIdentifiers.allSatisfy( { $0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } } ) else { return nil }
76+
self.prereleaseIdentifiers = prereleaseIdentifiers.map { String($0) }
77+
} else {
78+
self.prereleaseIdentifiers = []
79+
}
80+
81+
if let metadataDelimiterIndex = metadataDelimiterIndex {
82+
let metadataStartIndex = versionString.index(after: metadataDelimiterIndex)
83+
let buildMetadataIdentifiers = versionString[metadataStartIndex...].split(separator: ".", omittingEmptySubsequences: false)
84+
guard buildMetadataIdentifiers.allSatisfy( { $0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } } ) else { return nil }
85+
self.buildMetadataIdentifiers = buildMetadataIdentifiers.map { String($0) }
86+
} else {
87+
self.buildMetadataIdentifiers = []
8788
}
88-
89-
self.prereleaseIdentifiers = identifiers(
90-
start: prereleaseStartIndex,
91-
end: metadataStartIndex ?? versionString.endIndex)
92-
self.buildMetadataIdentifiers = identifiers(start: metadataStartIndex, end: versionString.endIndex)
9389
}
9490
}

Sources/PackageDescription/Version.swift

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2018 Apple Inc. and the Swift project authors
4+
Copyright (c) 2018 - 2021 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See http://swift.org/LICENSE.txt for license information
@@ -55,11 +55,11 @@ public struct Version {
5555
/// Initializes a version struct with the provided components of a semantic version.
5656
///
5757
/// - Parameters:
58-
/// - major: The major version number.
59-
/// - minor: The minor version number.
60-
/// - patch: The patch version number.
61-
/// - prereleaseIdentifiers: The pre-release identifier.
62-
/// - buildMetaDataIdentifiers: Build metadata that identifies a build.
58+
/// - major: The major version number.
59+
/// - minor: The minor version number.
60+
/// - patch: The patch version number.
61+
/// - prereleaseIdentifiers: The pre-release identifier.
62+
/// - buildMetaDataIdentifiers: Build metadata that identifies a build.
6363
public init(
6464
_ major: Int,
6565
_ minor: Int,
@@ -77,6 +77,12 @@ public struct Version {
7777
}
7878

7979
extension Version: Comparable {
80+
// Although `Comparable` inherits from `Equatable`, it does not provide a new default implementation of `==`, but instead uses `Equatable`'s default synthesised implementation. The compiler-synthesised `==`` is composed of [member-wise comparisons](https://github.com/apple/swift-evolution/blob/main/proposals/0185-synthesize-equatable-hashable.md#implementation-details), which leads to a false `false` when 2 semantic versions differ by only their build metadata identifiers, contradicting SemVer 2.0.0's [comparison rules](https://semver.org/#spec-item-10).
81+
@inlinable
82+
public static func == (lhs: Version, rhs: Version) -> Bool {
83+
!(lhs < rhs) && !(lhs > rhs)
84+
}
85+
8086
public static func < (lhs: Version, rhs: Version) -> Bool {
8187
let lhsComparators = [lhs.major, lhs.minor, lhs.patch]
8288
let rhsComparators = [rhs.major, rhs.minor, rhs.patch]

Tests/PackageDescription4Tests/VersionTests.swift

Lines changed: 0 additions & 27 deletions
This file was deleted.

0 commit comments

Comments
 (0)