-
Notifications
You must be signed in to change notification settings - Fork 121
/
Version.swift
369 lines (315 loc) · 17.4 KB
/
Version.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
/*
This source file is part of the Swift.org open source project
Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception
See http://swift.org/LICENSE.txt for license information
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
*/
import TSCBasic
/// A struct representing a semver version.
public struct Version: Sendable {
/// The major version.
public let major: Int
/// The minor version.
public let minor: Int
/// The patch version.
public let patch: Int
/// The pre-release identifier.
public let prereleaseIdentifiers: [String]
/// The build metadata.
public let buildMetadataIdentifiers: [String]
/// Creates a version object.
public init(
_ major: Int,
_ minor: Int,
_ patch: Int,
prereleaseIdentifiers: [String] = [],
buildMetadataIdentifiers: [String] = []
) {
precondition(major >= 0 && minor >= 0 && patch >= 0, "Negative versioning is invalid.")
self.major = major
self.minor = minor
self.patch = patch
self.prereleaseIdentifiers = prereleaseIdentifiers
self.buildMetadataIdentifiers = buildMetadataIdentifiers
}
}
/// An error that occurs during the creation of a version.
public enum VersionError: Error, CustomStringConvertible {
/// The version string contains non-ASCII characters.
/// - Parameter versionString: The version string.
case nonASCIIVersionString(_ versionString: String)
/// The version core contains an invalid number of Identifiers.
/// - Parameters:
/// - identifiers: The version core identifiers in the version string.
/// - usesLenientParsing: A Boolean value indicating whether or not the lenient parsing mode was enabled when this error occurred.
case invalidVersionCoreIdentifiersCount(_ identifiers: [String], usesLenientParsing: Bool)
/// Some or all of the version core identifiers contain non-numerical characters or are empty.
/// - Parameter identifiers: The version core identifiers in the version string.
case nonNumericalOrEmptyVersionCoreIdentifiers(_ identifiers: [String])
/// Some or all of the pre-release identifiers contain characters other than alpha-numerics and hyphens.
/// - Parameter identifiers: The pre-release identifiers in the version string.
case nonAlphaNumerHyphenalPrereleaseIdentifiers(_ identifiers: [String])
/// Some or all of the build metadata identifiers contain characters other than alpha-numerics and hyphens.
/// - Parameter identifiers: The build metadata identifiers in the version string.
case nonAlphaNumerHyphenalBuildMetadataIdentifiers(_ identifiers: [String])
public var description: String {
switch self {
case let .nonASCIIVersionString(versionString):
return "non-ASCII characters in version string '\(versionString)'"
case let .invalidVersionCoreIdentifiersCount(identifiers, usesLenientParsing):
return "\(identifiers.count > 3 ? "more than 3" : "fewer than \(usesLenientParsing ? 2 : 3)") identifiers in version core '\(identifiers.joined(separator: "."))'"
case let .nonNumericalOrEmptyVersionCoreIdentifiers(identifiers):
if !identifiers.allSatisfy( { !$0.isEmpty } ) {
return "empty identifiers in version core '\(identifiers.joined(separator: "."))'"
} else {
// Not checking for `.isASCII` here because non-ASCII characters should've already been caught before this.
let nonNumericalIdentifiers = identifiers.filter { !$0.allSatisfy(\.isNumber) }
return "non-numerical characters in version core identifier\(nonNumericalIdentifiers.count > 1 ? "s" : "") \(nonNumericalIdentifiers.map { "'\($0)'" } .joined(separator: ", "))"
}
case let .nonAlphaNumerHyphenalPrereleaseIdentifiers(identifiers):
// Not checking for `.isASCII` here because non-ASCII characters should've already been caught before this.
let nonAlphaNumericalIdentifiers = identifiers.filter { !$0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } }
return "characters other than alpha-numerics and hyphens in pre-release identifier\(nonAlphaNumericalIdentifiers.count > 1 ? "s" : "") \(nonAlphaNumericalIdentifiers.map { "'\($0)'" } .joined(separator: ", "))"
case let .nonAlphaNumerHyphenalBuildMetadataIdentifiers(identifiers):
// Not checking for `.isASCII` here because non-ASCII characters should've already been caught before this.
let nonAlphaNumericalIdentifiers = identifiers.filter { !$0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } }
return "characters other than alpha-numerics and hyphens in build metadata identifier\(nonAlphaNumericalIdentifiers.count > 1 ? "s" : "") \(nonAlphaNumericalIdentifiers.map { "'\($0)'" } .joined(separator: ", "))"
}
}
}
extension Version {
// TODO: Rename this function to `init(string:usesLenientParsing:) throws`, after `init?(string: String)` is removed.
// TODO: Find a better error-checking order.
// 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.
// But this is misleading the user to consider "forty" as a valid version core identifier.
// We should find a way to check for (or throw) "wrong characters used" errors first, but without overly-complicating the logic.
/// Creates a version from the given string.
/// - Parameters:
/// - versionString: The string to create the version from.
/// - usesLenientParsing: A Boolean value indicating whether or not the version string should be parsed leniently. If `true`, then the patch version is assumed to be `0` if it's not provided in the version string; otherwise, the parsing strictly follows the Semantic Versioning 2.0.0 rules. This value defaults to `false`.
/// - Throws: A `VersionError` instance if the `versionString` doesn't follow [SemVer 2.0.0](https://semver.org).
public init(versionString: String, usesLenientParsing: Bool = false) throws {
// 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.)
// Alphanumerics check will come later, after each identifier is split out (i.e. after the delimiters are removed).
guard versionString.allSatisfy(\.isASCII) else {
throw VersionError.nonASCIIVersionString(versionString)
}
let metadataDelimiterIndex = versionString.firstIndex(of: "+")
// SemVer 2.0.0 requires that pre-release identifiers come before build metadata identifiers
let prereleaseDelimiterIndex = versionString[..<(metadataDelimiterIndex ?? versionString.endIndex)].firstIndex(of: "-")
let versionCore = versionString[..<(prereleaseDelimiterIndex ?? metadataDelimiterIndex ?? versionString.endIndex)]
let versionCoreIdentifiers = versionCore.split(separator: ".", omittingEmptySubsequences: false)
guard versionCoreIdentifiers.count == 3 || (usesLenientParsing && versionCoreIdentifiers.count == 2) else {
throw VersionError.invalidVersionCoreIdentifiersCount(versionCoreIdentifiers.map { String($0) }, usesLenientParsing: usesLenientParsing)
}
guard
// Major, minor, and patch versions must be ASCII numbers, according to the semantic versioning standard.
// Converting each identifier from a substring to an integer doubles as checking if the identifiers have non-numeric characters.
let major = Int(versionCoreIdentifiers[0]),
let minor = Int(versionCoreIdentifiers[1]),
let patch = usesLenientParsing && versionCoreIdentifiers.count == 2 ? 0 : Int(versionCoreIdentifiers[2])
else {
throw VersionError.nonNumericalOrEmptyVersionCoreIdentifiers(versionCoreIdentifiers.map { String($0) })
}
self.major = major
self.minor = minor
self.patch = patch
if let prereleaseDelimiterIndex = prereleaseDelimiterIndex {
let prereleaseStartIndex = versionString.index(after: prereleaseDelimiterIndex)
let prereleaseIdentifiers = versionString[prereleaseStartIndex..<(metadataDelimiterIndex ?? versionString.endIndex)].split(separator: ".", omittingEmptySubsequences: false)
guard prereleaseIdentifiers.allSatisfy( { $0.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" }) } ) else {
throw VersionError.nonAlphaNumerHyphenalPrereleaseIdentifiers(prereleaseIdentifiers.map { String($0) })
}
self.prereleaseIdentifiers = prereleaseIdentifiers.map { String($0) }
} else {
self.prereleaseIdentifiers = []
}
if let metadataDelimiterIndex = metadataDelimiterIndex {
let metadataStartIndex = versionString.index(after: metadataDelimiterIndex)
let buildMetadataIdentifiers = versionString[metadataStartIndex...].split(separator: ".", omittingEmptySubsequences: false)
guard buildMetadataIdentifiers.allSatisfy( { $0.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" }) } ) else {
throw VersionError.nonAlphaNumerHyphenalBuildMetadataIdentifiers(buildMetadataIdentifiers.map { String($0) })
}
self.buildMetadataIdentifiers = buildMetadataIdentifiers.map { String($0) }
} else {
self.buildMetadataIdentifiers = []
}
}
}
extension Version: Comparable, Hashable {
func isEqualWithoutPrerelease(_ other: Version) -> Bool {
return major == other.major && minor == other.minor && patch == other.patch
}
// 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).
@inlinable
public static func == (lhs: Version, rhs: Version) -> Bool {
!(lhs < rhs) && !(lhs > rhs)
}
public static func < (lhs: Version, rhs: Version) -> Bool {
let lhsComparators = [lhs.major, lhs.minor, lhs.patch]
let rhsComparators = [rhs.major, rhs.minor, rhs.patch]
if lhsComparators != rhsComparators {
return lhsComparators.lexicographicallyPrecedes(rhsComparators)
}
guard lhs.prereleaseIdentifiers.count > 0 else {
return false // Non-prerelease lhs >= potentially prerelease rhs
}
guard rhs.prereleaseIdentifiers.count > 0 else {
return true // Prerelease lhs < non-prerelease rhs
}
for (lhsPrereleaseIdentifier, rhsPrereleaseIdentifier) in zip(lhs.prereleaseIdentifiers, rhs.prereleaseIdentifiers) {
if lhsPrereleaseIdentifier == rhsPrereleaseIdentifier {
continue
}
// Check if either of the 2 pre-release identifiers is numeric.
let lhsNumericPrereleaseIdentifier = Int(lhsPrereleaseIdentifier)
let rhsNumericPrereleaseIdentifier = Int(rhsPrereleaseIdentifier)
if let lhsNumericPrereleaseIdentifier = lhsNumericPrereleaseIdentifier,
let rhsNumericPrereleaseIdentifier = rhsNumericPrereleaseIdentifier {
return lhsNumericPrereleaseIdentifier < rhsNumericPrereleaseIdentifier
} else if lhsNumericPrereleaseIdentifier != nil {
return true // numeric pre-release < non-numeric pre-release
} else if rhsNumericPrereleaseIdentifier != nil {
return false // non-numeric pre-release > numeric pre-release
} else {
return lhsPrereleaseIdentifier < rhsPrereleaseIdentifier
}
}
return lhs.prereleaseIdentifiers.count < rhs.prereleaseIdentifiers.count
}
// Custom `Equatable` conformance leads to custom `Hashable` conformance.
// [SR-11588](https://bugs.swift.org/browse/SR-11588)
public func hash(into hasher: inout Hasher) {
hasher.combine(major)
hasher.combine(minor)
hasher.combine(patch)
hasher.combine(prereleaseIdentifiers)
}
}
extension Version: CustomStringConvertible {
public var description: String {
var base = "\(major).\(minor).\(patch)"
if !prereleaseIdentifiers.isEmpty {
base += "-" + prereleaseIdentifiers.joined(separator: ".")
}
if !buildMetadataIdentifiers.isEmpty {
base += "+" + buildMetadataIdentifiers.joined(separator: ".")
}
return base
}
}
extension Version: LosslessStringConvertible {
/// Initializes a version struct with the provided version string.
/// - Parameter version: A version string to use for creating a new version struct.
public init?(_ versionString: String) {
try? self.init(versionString: versionString)
}
}
extension Version {
// This initialiser is no longer necessary, but kept around for source compatibility with SwiftPM.
/// Create a version object from string.
/// - Parameter string: The string to parse.
@available(*, deprecated, renamed: "init(_:)")
public init?(string: String) {
self.init(string)
}
}
extension Version: ExpressibleByStringLiteral {
public init(stringLiteral value: String) {
guard let version = Version(value) else {
fatalError("\(value) is not a valid version")
}
self = version
}
public init(extendedGraphemeClusterLiteral value: String) {
self.init(stringLiteral: value)
}
public init(unicodeScalarLiteral value: String) {
self.init(stringLiteral: value)
}
}
extension Version: JSONMappable, JSONSerializable {
public init(json: JSON) throws {
guard case .string(let string) = json else {
throw JSON.MapError.custom(key: nil, message: "expected string, got \(json)")
}
guard let version = Version(string) else {
throw JSON.MapError.custom(key: nil, message: "Invalid version string \(string)")
}
self.init(version)
}
public func toJSON() -> JSON {
return .string(description)
}
init(_ version: Version) {
self.init(
version.major, version.minor, version.patch,
prereleaseIdentifiers: version.prereleaseIdentifiers,
buildMetadataIdentifiers: version.buildMetadataIdentifiers
)
}
}
extension Version: Codable {
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(description)
}
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
guard let version = Version(string) else {
throw DecodingError.dataCorrupted(.init(
codingPath: decoder.codingPath,
debugDescription: "Invalid version string \(string)"))
}
self.init(version)
}
}
// MARK:- Range operations
extension ClosedRange where Bound == Version {
/// Marked as unavailable because we have custom rules for contains.
public func contains(_ element: Version) -> Bool {
// Unfortunately, we can't use unavailable here.
fatalError("contains(_:) is unavailable, use contains(version:)")
}
}
// Disabled because compiler hits an assertion https://bugs.swift.org/browse/SR-5014
#if false
extension CountableRange where Bound == Version {
/// Marked as unavailable because we have custom rules for contains.
public func contains(_ element: Version) -> Bool {
// Unfortunately, we can't use unavailable here.
fatalError("contains(_:) is unavailable, use contains(version:)")
}
}
#endif
extension Range where Bound == Version {
/// Marked as unavailable because we have custom rules for contains.
public func contains(_ element: Version) -> Bool {
// Unfortunately, we can't use unavailable here.
fatalError("contains(_:) is unavailable, use contains(version:)")
}
}
extension Range where Bound == Version {
public func contains(version: Version) -> Bool {
// Special cases if version contains prerelease identifiers.
if !version.prereleaseIdentifiers.isEmpty {
// If the range does not contain prerelease identifiers, return false.
if lowerBound.prereleaseIdentifiers.isEmpty && upperBound.prereleaseIdentifiers.isEmpty {
return false
}
// At this point, one of the bounds contains prerelease identifiers.
//
// Reject 2.0.0-alpha when upper bound is 2.0.0.
if upperBound.prereleaseIdentifiers.isEmpty && upperBound.isEqualWithoutPrerelease(version) {
return false
}
}
if lowerBound == version {
return true
}
// Otherwise, apply normal contains rules.
return version >= lowerBound && version < upperBound
}
}