Skip to content

Commit 3540865

Browse files
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 "+". 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.
1 parent fc5b8a5 commit 3540865

File tree

2 files changed

+108
-39
lines changed

2 files changed

+108
-39
lines changed

Sources/TSCUtility/Version.swift

Lines changed: 57 additions & 36 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
@@ -103,47 +103,68 @@ extension Version: CustomStringConvertible {
103103
}
104104
}
105105

106-
public extension Version {
107-
108-
/// 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)
106+
extension Version: LosslessStringConvertible {
107+
/// Initializes a version struct with the provided version string.
108+
/// - Parameter version: A version string to use for creating a new version struct.
109+
public init?(_ versionString: String) {
110+
// 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.)
111+
// Alphanumerics check will come later, after each identifier is split out (i.e. after the delimiters are removed).
112+
guard versionString.allSatisfy(\.isASCII) else { return nil }
113+
114+
let metadataDelimiterIndex = versionString.firstIndex(of: "+")
115+
// SemVer 2.0.0 requires that pre-release identifiers come before build metadata identifiers
116+
let prereleaseDelimiterIndex = versionString[..<(metadataDelimiterIndex ?? versionString.endIndex)].firstIndex(of: "-")
117+
118+
let versionCore = versionString[..<(prereleaseDelimiterIndex ?? metadataDelimiterIndex ?? versionString.endIndex)]
119+
let versionCoreIdentifiers = versionCore.split(separator: ".", omittingEmptySubsequences: false)
120+
121+
guard
122+
versionCoreIdentifiers.count == 3,
123+
// Major, minor, and patch versions must be ASCII numbers, according to the semantic versioning standard.
124+
// Converting each identifier from a substring to an integer doubles as checking if the identifiers have non-numeric characters.
125+
let major = Int(versionCoreIdentifiers[0]),
126+
let minor = Int(versionCoreIdentifiers[1]),
127+
let patch = Int(versionCoreIdentifiers[2])
128+
else { return nil }
129+
130+
self.major = major
131+
self.minor = minor
132+
self.patch = patch
133+
134+
if prereleaseDelimiterIndex == nil {
135+
self.prereleaseIdentifiers = []
136+
} else {
137+
let prereleaseStartIndex = prereleaseDelimiterIndex.map(versionString.index(after:)) ?? metadataDelimiterIndex ?? versionString.endIndex
138+
let prereleaseIdentifiers = versionString[prereleaseStartIndex..<(metadataDelimiterIndex ?? versionString.endIndex)].split(separator: ".", omittingEmptySubsequences: false)
139+
guard prereleaseIdentifiers.allSatisfy( { $0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } } ) else { return nil }
140+
self.prereleaseIdentifiers = prereleaseIdentifiers.map { String($0) }
132141
}
142+
143+
if metadataDelimiterIndex == nil {
144+
self.buildMetadataIdentifiers = []
145+
} else {
146+
let metadataStartIndex = metadataDelimiterIndex.map(versionString.index(after:)) ?? versionString.endIndex
147+
let buildMetadataIdentifiers = versionString[metadataStartIndex...].split(separator: ".", omittingEmptySubsequences: false)
148+
guard buildMetadataIdentifiers.allSatisfy( { $0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } } ) else { return nil }
149+
self.buildMetadataIdentifiers = buildMetadataIdentifiers.map { String($0) }
150+
}
151+
}
152+
}
133153

134-
self.prereleaseIdentifiers = identifiers(
135-
start: prereleaseStartIndex,
136-
end: metadataStartIndex ?? string.endIndex)
137-
self.buildMetadataIdentifiers = identifiers(
138-
start: metadataStartIndex,
139-
end: string.endIndex)
154+
extension Version {
155+
// This initialiser is no longer necessary, but kept around for source compatibility with SwiftPM.
156+
/// Create a version object from string.
157+
/// - Parameter string: The string to parse.
158+
@available(*, deprecated, renamed: "init(_:)")
159+
public init?(string: String) {
160+
self.init(string)
140161
}
141162
}
142163

143164
extension Version: ExpressibleByStringLiteral {
144165

145166
public init(stringLiteral value: String) {
146-
guard let version = Version(string: value) else {
167+
guard let version = Version(value) else {
147168
fatalError("\(value) is not a valid version")
148169
}
149170
self = version
@@ -163,7 +184,7 @@ extension Version: JSONMappable, JSONSerializable {
163184
guard case .string(let string) = json else {
164185
throw JSON.MapError.custom(key: nil, message: "expected string, got \(json)")
165186
}
166-
guard let version = Version(string: string) else {
187+
guard let version = Version(string) else {
167188
throw JSON.MapError.custom(key: nil, message: "Invalid version string \(string)")
168189
}
169190
self.init(version)
@@ -192,7 +213,7 @@ extension Version: Codable {
192213
let container = try decoder.singleValueContainer()
193214
let string = try container.decode(String.self)
194215

195-
guard let version = Version(string: string) else {
216+
guard let version = Version(string) else {
196217
throw DecodingError.dataCorrupted(.init(
197218
codingPath: decoder.codingPath,
198219
debugDescription: "Invalid version string \(string)"))

Tests/TSCUtilityTests/VersionTests.swift

Lines changed: 51 additions & 3 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
@@ -56,8 +56,55 @@ class VersionTests: XCTestCase {
5656
XCTAssertEqual(v.prereleaseIdentifiers, ["alpha", "beta"])
5757
XCTAssertEqual(v.buildMetadataIdentifiers, ["sha1", "1011"])
5858
}
59-
60-
func testFromString() {
59+
60+
func testLosslessConversionFromStringToVersion() {
61+
62+
// We use type coercion `as String` in `Version(_:)` because there is a pair of overloaded initializers: `init(_ version: Version)` and `init?(_ versionString: String)`, and we want to test the latter in this function.
63+
64+
// MARK: Well-formed version core
65+
66+
XCTAssertNotNil(Version("0.0.0" as String))
67+
XCTAssertEqual(Version("0.0.0" as String), Version(0, 0, 0))
68+
69+
XCTAssertNotNil(Version("1.1.2" as String))
70+
XCTAssertEqual(Version("1.1.2" as String), Version(1, 1, 2))
71+
72+
// MARK: Well-formed version core, well-formed pre-release identifiers
73+
74+
XCTAssertNotNil(Version("0.0.0-pre-alpha" as String))
75+
XCTAssertEqual(Version("0.0.0-pre-alpha" as String), Version(0, 0, 0, prereleaseIdentifiers: ["pre-alpha"]))
76+
77+
XCTAssertNotNil(Version("55.89.144-beta.1" as String))
78+
XCTAssertEqual(Version("55.89.144-beta.1" as String), Version(55, 89, 144, prereleaseIdentifiers: ["beta", "1"]))
79+
80+
XCTAssertNotNil(Version("89.144.233-a.whole..lot.of.pre-release.identifiers" as String))
81+
XCTAssertEqual(Version("89.144.233-a.whole..lot.of.pre-release.identifiers" as String), Version(89, 144, 233, prereleaseIdentifiers: ["a", "whole", "", "lot", "of", "pre-release", "identifiers"]))
82+
83+
XCTAssertNotNil(Version("144.233.377-" as String))
84+
XCTAssertEqual(Version("144.233.377-" as String), Version(144, 233, 377, prereleaseIdentifiers: [""]))
85+
86+
// MARK: Well-formed version core, well-formed build metadata identifiers
87+
88+
XCTAssertNotNil(Version("0.0.0+some-metadata" as String))
89+
XCTAssertEqual(Version("0.0.0+some-metadata" as String), Version(0, 0, 0, buildMetadataIdentifiers: ["some-metadata"]))
90+
91+
XCTAssertNotNil(Version("4181.6765.10946+more.meta..more.data" as String))
92+
XCTAssertEqual(Version("4181.6765.10946+more.meta..more.data" as String), Version(4181, 6765, 10946, buildMetadataIdentifiers: ["more", "meta", "", "more", "data"]))
93+
94+
XCTAssertNotNil(Version("6765.10946.17711+-a-very--long---build-----metadata--------identifier-------------with---------------------many----------------------------------hyphens-------------------------------------------------------" as String))
95+
XCTAssertEqual(Version("6765.10946.17711+-a-very--long---build-----metadata--------identifier-------------with---------------------many----------------------------------hyphens-------------------------------------------------------" as String), Version(6765, 10946, 17711, buildMetadataIdentifiers: ["-a-very--long---build-----metadata--------identifier-------------with---------------------many----------------------------------hyphens-------------------------------------------------------"]))
96+
97+
XCTAssertNotNil(Version("10946.17711.28657+" as String))
98+
XCTAssertEqual(Version("10946.17711.28657+" as String), Version(10946, 17711, 28657, buildMetadataIdentifiers: [""]))
99+
100+
// MARK: Well-formed version core, well-formed pre-release identifiers, well-formed build metadata identifiers
101+
102+
XCTAssertNotNil(Version("0.0.0-beta.-42+42-42.42" as String))
103+
XCTAssertEqual(Version("0.0.0-beta.-42+42-42.42" as String), Version(0, 0, 0, prereleaseIdentifiers: ["beta", "-42"], buildMetadataIdentifiers: ["42-42", "42"]))
104+
105+
}
106+
107+
func testAdditionalInitializationFromString() {
61108
let badStrings = [
62109
"", "1", "1.2", "1.2.3.4", "1.2.3.4.5",
63110
"a", "1.a", "a.2", "a.2.3", "1.a.3", "1.2.a",
@@ -285,4 +332,5 @@ class VersionTests: XCTestCase {
285332
XCTAssertFalse(range.contains(version: "1.1.0-beta"))
286333
}
287334
}
335+
288336
}

0 commit comments

Comments
 (0)