Skip to content

Commit fa2e77b

Browse files
committed
Require Swift 5.7, add missing validation of prerelease identifiers, a little misc. cleanup
1 parent 2be090a commit fa2e77b

File tree

4 files changed

+82
-62
lines changed

4 files changed

+82
-62
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,4 @@ on:
88

99
jobs:
1010
unit-tests:
11-
uses: vapor/ci/.github/workflows/run-unit-tests.yml@reusable-workflows
12-
with:
13-
with_coverage: true
14-
with_tsan: true
11+
uses: vapor/ci/.github/workflows/run-unit-tests.yml@main

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// swift-tools-version:5.6
1+
// swift-tools-version:5.7
22
//===----------------------------------------------------------------------===//
33
//
44
// This source file is part of the swift-semver open source project

Sources/SwiftSemver/SemanticVersion.swift

Lines changed: 72 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,12 @@ public struct SemanticVersion: Sendable, Hashable {
5757
prereleaseIdentifiers: [String] = [],
5858
buildMetadataIdentifiers: [String] = []
5959
) {
60-
guard (prereleaseIdentifiers + buildMetadataIdentifiers).allSatisfyValidSemverIdentifier else {
60+
guard (prereleaseIdentifiers + buildMetadataIdentifiers).allSatisfy({ $0.allSatisfy(\.isValidInSemverIdentifier) }) else {
6161
fatalError("Invalid character found in semver identifier, must match [A-Za-z0-9-]")
6262
}
63+
guard prereleaseIdentifiers.allSatisfy({ $0.isValidSemverPrereleaseIdentifier }) else {
64+
fatalError("Invalid prerelease identifier found, must be alphanumeric, exactly 0, or not start with 0.")
65+
}
6366
self.major = major
6467
self.minor = minor
6568
self.patch = patch
@@ -74,22 +77,19 @@ public struct SemanticVersion: Sendable, Hashable {
7477
/// - TODO: Possibly throw more specific validation errors? Would this be useful?
7578
///
7679
/// - TODO: This code, while it does check validity better than what was here before, is ugly as heck. Clean it up.
77-
public init?(string: String) {
78-
guard string.allSatisfy({ $0.isASCII }) else { return nil }
80+
public init?(string: some StringProtocol) {
81+
guard string.allSatisfy(\.isASCII) else { return nil }
7982

8083
var idx = string.startIndex
8184
func readNumber(usingIdx idx: inout String.Index) -> UInt? {
8285
let startIdx = idx
83-
while idx < string.endIndex, string[idx].isNumber { string.formIndex(after: &idx) }
84-
let endIdx = idx
85-
return string.distance(from: startIdx, to: endIdx) > 0 ? UInt(string[startIdx..<endIdx]) : nil
86+
idx = string[idx...].firstIndex(where: { !$0.isWholeNumber }) ?? string.endIndex
87+
return UInt(string[startIdx ..< idx])
8688
}
8789
func readIdent(usingIdx idx: inout String.Index) -> String? {
8890
let startIdx = idx
89-
while idx < string.endIndex, string[idx].isValidInSemverIdentifier { string.formIndex(after: &idx) }
90-
let endIdx = idx
91-
guard string.distance(from: startIdx, to: endIdx) > 0 else { return nil }
92-
return String(string[startIdx..<endIdx])
91+
idx = string[idx...].firstIndex(where: { !$0.isValidInSemverIdentifier }) ?? string.endIndex
92+
return idx > startIdx ? String(string[startIdx ..< idx]) : nil
9393
}
9494

9595
guard let major = readNumber(usingIdx: &idx) else { return nil }
@@ -119,8 +119,14 @@ public struct SemanticVersion: Sendable, Hashable {
119119
}
120120
string.formIndex(after: &idx)
121121
guard let ident = readIdent(usingIdx: &idx) else { return nil }
122-
if seenPlus { buildMetadataIdentifiers.append(ident) }
123-
else { prereleaseIdentifiers.append(ident) }
122+
if seenPlus {
123+
buildMetadataIdentifiers.append(ident)
124+
} else {
125+
guard ident.isValidSemverPrereleaseIdentifier else {
126+
return nil
127+
}
128+
prereleaseIdentifiers.append(ident)
129+
}
124130
}
125131

126132
self.major = major
@@ -132,7 +138,7 @@ public struct SemanticVersion: Sendable, Hashable {
132138
}
133139

134140
extension SemanticVersion: Comparable {
135-
/// See ``Comparable/<(lhs:rhs:)``. Implements the "precedence" ordering specified by the semver specification.
141+
/// Implements the "precedence" ordering specified by the semver specification.
136142
public static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
137143
let lhsComponents = [lhs.major, lhs.minor, lhs.patch]
138144
let rhsComponents = [rhs.major, rhs.minor, rhs.patch]
@@ -145,8 +151,8 @@ extension SemanticVersion: Comparable {
145151
if rhs.prereleaseIdentifiers.isEmpty { return true } // Prerelease lhs < non-prerelease rhs
146152

147153
switch zip(lhs.prereleaseIdentifiers, rhs.prereleaseIdentifiers)
148-
.first(where: { $0 != $1 })
149-
.map({ ((Int($0) ?? $0) as Any, (Int($1) ?? $1) as Any) })
154+
.first(where: { $0 != $1 })
155+
.map({ ((Int($0) ?? $0) as Any, (Int($1) ?? $1) as Any) })
150156
{
151157
case let .some((lId as Int, rId as Int)): return lId < rId
152158
case let .some((lId as String, rId as String)): return lId < rId
@@ -160,32 +166,32 @@ extension SemanticVersion: Comparable {
160166
}
161167

162168
extension SemanticVersion: LosslessStringConvertible {
163-
/// See ``CustomStringConvertible/description``. An additional API guarantee is made that this property will always
164-
/// yield a string which is correctly formatted as a valid semantic version number.
169+
/// An additional API guarantee is made by this type that this property will always yield a string
170+
/// which is correctly formatted as a valid semantic version number.
165171
public var description: String {
166-
return """
167-
\(major).\
168-
\(minor).\
169-
\(patch)\
170-
\(prereleaseIdentifiers.joined(separator: ".", prefix: "-"))\
171-
\(buildMetadataIdentifiers.joined(separator: ".", prefix: "+"))
172-
"""
172+
"""
173+
\(self.major).\
174+
\(self.minor).\
175+
\(self.patch)\
176+
\(self.prereleaseIdentifiers.joined(separator: ".", prefix: "-"))\
177+
\(self.buildMetadataIdentifiers.joined(separator: ".", prefix: "+"))
178+
"""
173179
}
174180

175-
/// See ``LosslessStringConvertible/init(_:)``. The semantics are identical to those of ``init?(string:)``.
181+
// See `LosslessStringConvertible.init(_:)`. Identical semantics to ``init?(string:)``.
176182
public init?(_ description: String) {
177183
self.init(string: description)
178184
}
179185
}
180186

181187
extension SemanticVersion: Codable {
182-
/// See ``Encodable/encode(to:)``.
183-
public func encode(to encoder: Encoder) throws {
188+
// See `Encodable.encode(to:)`.
189+
public func encode(to encoder: any Encoder) throws {
184190
try self.description.encode(to: encoder)
185191
}
186192

187-
/// See ``Decodable/init(from:)``.
188-
public init(from decoder: Decoder) throws {
193+
// See `Decodable.init(from:)`.
194+
public init(from decoder: any Decoder) throws {
189195
let container = try decoder.singleValueContainer()
190196
let raw = try container.decode(String.self)
191197

@@ -196,46 +202,56 @@ extension SemanticVersion: Codable {
196202
}
197203
}
198204

199-
fileprivate extension Array where Element == String {
205+
fileprivate extension Array<String> {
200206
/// Identical to ``joined(separator:)``, except that when the result is non-empty, the provided `prefix` will be
201207
/// prepended to it. This is a mildly silly solution to the issue of how best to implement "add a joiner character
202208
/// between one interpolation and the next, but only if the second one is non-empty".
203209
func joined(separator: String, prefix: String) -> String {
204-
let result = self.joined(separator: separator)
205-
return (result.isEmpty ? "" : prefix) + result
210+
self.isEmpty ? "" : "\(prefix)\(self.joined(separator: separator))"
206211
}
207212
}
208213

209214
fileprivate extension Character {
210-
/// Valid characters in a semver identifier are defined by these BNF rules:
215+
/// Valid characters in a semver identifier are defined by these BNF rules,
216+
/// taken directly from [the SemVer BNF grammar][semver2bnf]:
217+
///
218+
/// [semver2bnf]: https://semver.org/spec/v2.0.0.html#backusnaur-form-grammar-for-valid-semver-versions
219+
///
220+
/// ```bnf
221+
/// <identifier character> ::= <digit>
222+
/// | <non-digit>
223+
///
224+
/// <non-digit> ::= <letter>
225+
/// | "-"
226+
///
227+
/// <digit> ::= "0"
228+
/// | <positive digit>
211229
///
212-
/// <identifier character> ::= <digit>
213-
/// | <non-digit>
214-
/// <non-digit> ::= <letter>
215-
/// | "-"
216-
/// <digit> ::= "0"
217-
/// | <positive digit>
218-
/// <positive digit> ::= "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
219-
/// <letter> ::= "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J"
220-
/// | "K" | "L" | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T"
221-
/// | "U" | "V" | "W" | "X" | "Y" | "Z" | "a" | "b" | "c" | "d"
222-
/// | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n"
223-
/// | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x"
224-
/// | "y" | "z"
230+
/// <positive digit> ::= "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
231+
///
232+
/// <letter> ::= "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J"
233+
/// | "K" | "L" | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T"
234+
/// | "U" | "V" | "W" | "X" | "Y" | "Z" | "a" | "b" | "c" | "d"
235+
/// | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n"
236+
/// | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x"
237+
/// | "y" | "z"
238+
/// ```
225239
var isValidInSemverIdentifier: Bool {
226-
self.isASCII && (self.isLetter || self.isNumber || self == "-")
240+
self.isLetter || self.isWholeNumber || self == "-"
227241
}
228242
}
229243

230244
fileprivate extension StringProtocol {
231-
/// See ``Character/isValidInSemverIdentifier`` for validity rules.
232-
var isValidSemverIdentifier: Bool {
233-
return self.allSatisfy { $0.isValidInSemverIdentifier }
234-
}
235-
}
236-
237-
fileprivate extension Collection where Element: StringProtocol {
238-
var allSatisfyValidSemverIdentifier: Bool {
239-
return self.allSatisfy { $0.isValidSemverIdentifier }
245+
/// A valid prerelease identifier must either:
246+
///
247+
/// - Be exactly "0",
248+
/// - Not start with "0", or
249+
/// - Contain non-numeric characters
250+
///
251+
///
252+
var isValidSemverPrereleaseIdentifier: Bool {
253+
self == "0" ||
254+
!self.starts(with: "0") ||
255+
self.contains { !$0.isWholeNumber }
240256
}
241257
}

Tests/SwiftSemverTests/SwiftSemverTests.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,15 @@ final class SwiftSemverTests: XCTestCase {
8484
XCTAssertEqual(SemanticVersion("1.2.3-1.dev"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["1", "dev"]))
8585
XCTAssertEqual(SemanticVersion("1.2.3-dev-a"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["dev-a"]))
8686
XCTAssertEqual(SemanticVersion("1.2.3--dev"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["-dev"]))
87+
XCTAssertEqual(SemanticVersion("1.2.3-dev.0"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["dev", "0"]))
88+
XCTAssertEqual(SemanticVersion("1.2.3-dev.1"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["dev", "1"]))
89+
XCTAssertEqual(SemanticVersion("1.2.3-0"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["0"]))
90+
XCTAssertEqual(SemanticVersion("1.2.3-1"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["1"]))
8791

8892
for badStr in [
8993
"1.2.3-!dev", "1.2.3-dev!", "1.2.3-d!ev", // non-letter characters
90-
"1.2.3-dev-!1", "1.2.3-dev-1!", "1.2.3-dev.!",
94+
"1.2.3-dev-!1", "1.2.3-dev-1!", "1.2.3-dev.!", // more non-letter characters
95+
"1.2.3-dev.01", "1.2.3-dev.00", // non-zero numeric starting with 0
9196
] {
9297
XCTAssertNil(SemanticVersion("\(badStr)"))
9398
XCTAssertNil(SemanticVersion("\(badStr)+b"))
@@ -102,6 +107,8 @@ final class SwiftSemverTests: XCTestCase {
102107
XCTAssertEqual(SemanticVersion("1.2.3+1.dev"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: [], buildMetadataIdentifiers: ["1", "dev"]))
103108
XCTAssertEqual(SemanticVersion("1.2.3+dev-a"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: [], buildMetadataIdentifiers: ["dev-a"]))
104109
XCTAssertEqual(SemanticVersion("1.2.3+-dev"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: [], buildMetadataIdentifiers: ["-dev"]))
110+
XCTAssertEqual(SemanticVersion("1.2.3+dev.0"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: [], buildMetadataIdentifiers: ["dev", "0"]))
111+
XCTAssertEqual(SemanticVersion("1.2.3+dev.01"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: [], buildMetadataIdentifiers: ["dev", "01"]))
105112

106113
for badStr in [
107114
"1.2.3+!dev", "1.2.3+dev!", "1.2.3+d!ev", // non-letter characters

0 commit comments

Comments
 (0)