Skip to content

Commit df0b2ea

Browse files
[TSCUtility] Correct semantic version parsing and comparison (#214)
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 "+". The initialiser wherein the parsing takes place has been renamed from `init?(string: String)` to `init?(_ versionString: String)`. The old initialiser is not removed but marked as deprecated for source compatibility with SwiftPM. With the new initialiser name, `Version` now conforms to `LosslessStringConvertible`. In addition, the logic for breaking up the version core into numeric identifiers has been rewritten to be more understandable. `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). This commit adds a manual implementation of `==` for `Version`, along with appropriate tests. One consequence, though, is that now two versions that differ by only their build metadata identifiers are not allowed in the same set. Because we have a non-synthesised `Equatable` conformance, the synthesised `Hashable` conformance composed of member-wise hashes is incorrect. `buildMetadataIdentifiers` does not participate in `Version`'s `Equatable` conformance, so it shouldn't participate in `Version`'s `Hashable` conformance either. Relevant: [SR-11588](https://bugs.swift.org/browse/SR-11588) other changes: * add additional tests for initialising `Version` * rearranged the tests. * [garderning] fix typo "ranage" → "range" * add a throwing initialiser for creating a version from a string This new initialiser throws a `VersionError` instance when initialisation fails. This gives the user more information and control over error handling. `Version`'s conformance to `LosslessStringConvertible` is preserved by having `init?(_ versionString: String)` call this new initialiser, and return `nil` when an error is thrown. * [gardening] remove horizontal whitespace from whitespace-only lines
1 parent 7d8933a commit df0b2ea

File tree

2 files changed

+1055
-98
lines changed

2 files changed

+1055
-98
lines changed

Sources/TSCUtility/Version.swift

Lines changed: 141 additions & 42 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) 2014 - 2017 Apple Inc. and the Swift project authors
4+
Copyright (c) 2014 - 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
@@ -11,7 +11,7 @@
1111
import TSCBasic
1212

1313
/// A struct representing a semver version.
14-
public struct Version: Hashable {
14+
public struct Version {
1515

1616
/// The major version.
1717
public let major: Int
@@ -28,7 +28,7 @@ public struct Version: Hashable {
2828
/// The build metadata.
2929
public let buildMetadataIdentifiers: [String]
3030

31-
/// Create a version object.
31+
/// Creates a version object.
3232
public init(
3333
_ major: Int,
3434
_ minor: Int,
@@ -45,12 +45,122 @@ public struct Version: Hashable {
4545
}
4646
}
4747

48-
extension Version: Comparable {
48+
/// An error that occurs during the creation of a version.
49+
public enum VersionError: Error, CustomStringConvertible {
50+
/// The version string contains non-ASCII characters.
51+
case nonASCIIVersionString(_ versionString: String)
52+
/// The version core contains an invalid number of Identifiers.
53+
case invalidVersionCoreIdentifiersCount(_ identifiers: [String])
54+
/// Some or all of the version core identifiers contain non-numerical characters or are empty.
55+
case nonNumericalOrEmptyVersionCoreIdentifiers(_ identifiers: [String])
56+
/// Some or all of the pre-release identifiers contain characters other than alpha-numerics and hyphens.
57+
case nonAlphaNumerHyphenalPrereleaseIdentifiers(_ identifiers: [String])
58+
/// Some or all of the build metadata identifiers contain characters other than alpha-numerics and hyphens.
59+
case nonAlphaNumerHyphenalBuildMetadataIdentifiers(_ identifiers: [String])
60+
61+
public var description: String {
62+
switch self {
63+
case let .nonASCIIVersionString(versionString):
64+
return "non-ASCII characters in version string '\(versionString)'"
65+
case let .invalidVersionCoreIdentifiersCount(identifiers):
66+
return "\(identifiers.count < 3 ? "fewer" : "more") than 3 identifiers in version core '\(identifiers.joined(separator: "."))'"
67+
case let .nonNumericalOrEmptyVersionCoreIdentifiers(identifiers):
68+
if !identifiers.allSatisfy( { !$0.isEmpty } ) {
69+
return "empty identifiers in version core '\(identifiers.joined(separator: "."))'"
70+
} else {
71+
// Not checking for `.isASCII` here because non-ASCII characters should've already been caught before this.
72+
let nonNumericalIdentifiers = identifiers.filter { !$0.allSatisfy(\.isNumber) }
73+
return "non-numerical characters in version core identifier\(nonNumericalIdentifiers.count > 1 ? "s" : "") \(nonNumericalIdentifiers.map { "'\($0)'" } .joined(separator: ", "))"
74+
}
75+
case let .nonAlphaNumerHyphenalPrereleaseIdentifiers(identifiers):
76+
// Not checking for `.isASCII` here because non-ASCII characters should've already been caught before this.
77+
let nonAlphaNumericalIdentifiers = identifiers.filter { !$0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } }
78+
return "characters other than alpha-numerics and hyphens in pre-release identifier\(nonAlphaNumericalIdentifiers.count > 1 ? "s" : "") \(nonAlphaNumericalIdentifiers.map { "'\($0)'" } .joined(separator: ", "))"
79+
case let .nonAlphaNumerHyphenalBuildMetadataIdentifiers(identifiers):
80+
// Not checking for `.isASCII` here because non-ASCII characters should've already been caught before this.
81+
let nonAlphaNumericalIdentifiers = identifiers.filter { !$0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } }
82+
return "characters other than alpha-numerics and hyphens in build metadata identifier\(nonAlphaNumericalIdentifiers.count > 1 ? "s" : "") \(nonAlphaNumericalIdentifiers.map { "'\($0)'" } .joined(separator: ", "))"
83+
}
84+
}
85+
}
86+
87+
extension Version {
88+
// TODO: Rename this function to `init(string: String) throws`, after `init?(string: String)` is removed.
89+
// TODO: Find a better error-checking order.
90+
// Currently, if a version string is "forty-two", this initializer throws an error that says "forty" is only 1 version core identifier, which is not enough.
91+
// But this is misleading the user to consider "forty" as a valid version core identifier.
92+
// We should find a way to check for (or throw) "wrong characters used" errors first, but without overly-complicating the logic.
93+
/// Creates a version from the given string.
94+
/// - Parameter versionString: The string to create the version from.
95+
/// - Throws: A `VersionError` instance if the `versionString` doesn't follow [SemVer 2.0.0](https://semver.org).
96+
public init(versionString: String) throws {
97+
// 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.)
98+
// Alphanumerics check will come later, after each identifier is split out (i.e. after the delimiters are removed).
99+
guard versionString.allSatisfy(\.isASCII) else {
100+
throw VersionError.nonASCIIVersionString(versionString)
101+
}
102+
103+
let metadataDelimiterIndex = versionString.firstIndex(of: "+")
104+
// SemVer 2.0.0 requires that pre-release identifiers come before build metadata identifiers
105+
let prereleaseDelimiterIndex = versionString[..<(metadataDelimiterIndex ?? versionString.endIndex)].firstIndex(of: "-")
106+
107+
let versionCore = versionString[..<(prereleaseDelimiterIndex ?? metadataDelimiterIndex ?? versionString.endIndex)]
108+
let versionCoreIdentifiers = versionCore.split(separator: ".", omittingEmptySubsequences: false)
109+
110+
guard versionCoreIdentifiers.count == 3 else {
111+
throw VersionError.invalidVersionCoreIdentifiersCount(versionCoreIdentifiers.map { String($0) })
112+
}
113+
114+
guard
115+
// Major, minor, and patch versions must be ASCII numbers, according to the semantic versioning standard.
116+
// Converting each identifier from a substring to an integer doubles as checking if the identifiers have non-numeric characters.
117+
let major = Int(versionCoreIdentifiers[0]),
118+
let minor = Int(versionCoreIdentifiers[1]),
119+
let patch = Int(versionCoreIdentifiers[2])
120+
else {
121+
throw VersionError.nonNumericalOrEmptyVersionCoreIdentifiers(versionCoreIdentifiers.map { String($0) })
122+
}
123+
124+
self.major = major
125+
self.minor = minor
126+
self.patch = patch
127+
128+
if let prereleaseDelimiterIndex = prereleaseDelimiterIndex {
129+
let prereleaseStartIndex = versionString.index(after: prereleaseDelimiterIndex)
130+
let prereleaseIdentifiers = versionString[prereleaseStartIndex..<(metadataDelimiterIndex ?? versionString.endIndex)].split(separator: ".", omittingEmptySubsequences: false)
131+
guard prereleaseIdentifiers.allSatisfy( { $0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } } ) else {
132+
throw VersionError.nonAlphaNumerHyphenalPrereleaseIdentifiers(prereleaseIdentifiers.map { String($0) })
133+
}
134+
self.prereleaseIdentifiers = prereleaseIdentifiers.map { String($0) }
135+
} else {
136+
self.prereleaseIdentifiers = []
137+
}
138+
139+
if let metadataDelimiterIndex = metadataDelimiterIndex {
140+
let metadataStartIndex = versionString.index(after: metadataDelimiterIndex)
141+
let buildMetadataIdentifiers = versionString[metadataStartIndex...].split(separator: ".", omittingEmptySubsequences: false)
142+
guard buildMetadataIdentifiers.allSatisfy( { $0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } } ) else {
143+
throw VersionError.nonAlphaNumerHyphenalBuildMetadataIdentifiers(buildMetadataIdentifiers.map { String($0) })
144+
}
145+
self.buildMetadataIdentifiers = buildMetadataIdentifiers.map { String($0) }
146+
} else {
147+
self.buildMetadataIdentifiers = []
148+
}
149+
}
150+
}
151+
152+
extension Version: Comparable, Hashable {
49153

50154
func isEqualWithoutPrerelease(_ other: Version) -> Bool {
51155
return major == other.major && minor == other.minor && patch == other.patch
52156
}
53157

158+
// 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).
159+
@inlinable
160+
public static func == (lhs: Version, rhs: Version) -> Bool {
161+
!(lhs < rhs) && !(lhs > rhs)
162+
}
163+
54164
public static func < (lhs: Version, rhs: Version) -> Bool {
55165
let lhsComparators = [lhs.major, lhs.minor, lhs.patch]
56166
let rhsComparators = [rhs.major, rhs.minor, rhs.patch]
@@ -64,7 +174,7 @@ extension Version: Comparable {
64174
}
65175

66176
guard rhs.prereleaseIdentifiers.count > 0 else {
67-
return true // Prerelease lhs < non-prerelease rhs
177+
return true // Prerelease lhs < non-prerelease rhs
68178
}
69179

70180
let zippedIdentifiers = zip(lhs.prereleaseIdentifiers, rhs.prereleaseIdentifiers)
@@ -88,6 +198,15 @@ extension Version: Comparable {
88198

89199
return lhs.prereleaseIdentifiers.count < rhs.prereleaseIdentifiers.count
90200
}
201+
202+
// Custom `Equatable` conformance leads to custom `Hashable` conformance.
203+
// [SR-11588](https://bugs.swift.org/browse/SR-11588)
204+
public func hash(into hasher: inout Hasher) {
205+
hasher.combine(major)
206+
hasher.combine(minor)
207+
hasher.combine(patch)
208+
hasher.combine(prereleaseIdentifiers)
209+
}
91210
}
92211

93212
extension Version: CustomStringConvertible {
@@ -103,47 +222,28 @@ extension Version: CustomStringConvertible {
103222
}
104223
}
105224

106-
public extension Version {
225+
extension Version: LosslessStringConvertible {
226+
/// Initializes a version struct with the provided version string.
227+
/// - Parameter version: A version string to use for creating a new version struct.
228+
public init?(_ versionString: String) {
229+
try? self.init(versionString: versionString)
230+
}
231+
}
107232

233+
extension Version {
234+
// This initialiser is no longer necessary, but kept around for source compatibility with SwiftPM.
108235
/// Create a version object from string.
109-
///
110-
/// - Parameters:
111-
/// - string: The string to parse.
112-
init?(string: String) {
113-
let prereleaseStartIndex = string.firstIndex(of: "-")
114-
let metadataStartIndex = string.firstIndex(of: "+")
115-
116-
let requiredEndIndex = prereleaseStartIndex ?? metadataStartIndex ?? string.endIndex
117-
let requiredCharacters = string.prefix(upTo: requiredEndIndex)
118-
let requiredComponents = requiredCharacters
119-
.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
120-
.map(String.init).compactMap({ Int($0) }).filter({ $0 >= 0 })
121-
122-
guard requiredComponents.count == 3 else { return nil }
123-
124-
self.major = requiredComponents[0]
125-
self.minor = requiredComponents[1]
126-
self.patch = requiredComponents[2]
127-
128-
func identifiers(start: String.Index?, end: String.Index) -> [String] {
129-
guard let start = start else { return [] }
130-
let identifiers = string[string.index(after: start)..<end]
131-
return identifiers.split(separator: ".").map(String.init)
132-
}
133-
134-
self.prereleaseIdentifiers = identifiers(
135-
start: prereleaseStartIndex,
136-
end: metadataStartIndex ?? string.endIndex)
137-
self.buildMetadataIdentifiers = identifiers(
138-
start: metadataStartIndex,
139-
end: string.endIndex)
236+
/// - Parameter string: The string to parse.
237+
@available(*, deprecated, renamed: "init(_:)")
238+
public init?(string: String) {
239+
self.init(string)
140240
}
141241
}
142242

143243
extension Version: ExpressibleByStringLiteral {
144244

145245
public init(stringLiteral value: String) {
146-
guard let version = Version(string: value) else {
246+
guard let version = Version(value) else {
147247
fatalError("\(value) is not a valid version")
148248
}
149249
self = version
@@ -163,7 +263,7 @@ extension Version: JSONMappable, JSONSerializable {
163263
guard case .string(let string) = json else {
164264
throw JSON.MapError.custom(key: nil, message: "expected string, got \(json)")
165265
}
166-
guard let version = Version(string: string) else {
266+
guard let version = Version(string) else {
167267
throw JSON.MapError.custom(key: nil, message: "Invalid version string \(string)")
168268
}
169269
self.init(version)
@@ -192,7 +292,7 @@ extension Version: Codable {
192292
let container = try decoder.singleValueContainer()
193293
let string = try container.decode(String.self)
194294

195-
guard let version = Version(string: string) else {
295+
guard let version = Version(string) else {
196296
throw DecodingError.dataCorrupted(.init(
197297
codingPath: decoder.codingPath,
198298
debugDescription: "Invalid version string \(string)"))
@@ -232,11 +332,10 @@ extension Range where Bound == Version {
232332
}
233333

234334
extension Range where Bound == Version {
235-
236335
public func contains(version: Version) -> Bool {
237336
// Special cases if version contains prerelease identifiers.
238337
if !version.prereleaseIdentifiers.isEmpty {
239-
// If the ranage does not contain prerelease identifiers, return false.
338+
// If the range does not contain prerelease identifiers, return false.
240339
if lowerBound.prereleaseIdentifiers.isEmpty && upperBound.prereleaseIdentifiers.isEmpty {
241340
return false
242341
}

0 commit comments

Comments
 (0)