Skip to content

Commit

Permalink
Require Swift 5.7, add missing validation of prerelease identifiers, …
Browse files Browse the repository at this point in the history
…a little misc. cleanup
  • Loading branch information
gwynne committed Sep 10, 2023
1 parent 2be090a commit fa2e77b
Show file tree
Hide file tree
Showing 4 changed files with 82 additions and 62 deletions.
5 changes: 1 addition & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,4 @@ on:

jobs:
unit-tests:
uses: vapor/ci/.github/workflows/run-unit-tests.yml@reusable-workflows
with:
with_coverage: true
with_tsan: true
uses: vapor/ci/.github/workflows/run-unit-tests.yml@main
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.6
// swift-tools-version:5.7
//===----------------------------------------------------------------------===//
//
// This source file is part of the swift-semver open source project
Expand Down
128 changes: 72 additions & 56 deletions Sources/SwiftSemver/SemanticVersion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,12 @@ public struct SemanticVersion: Sendable, Hashable {
prereleaseIdentifiers: [String] = [],
buildMetadataIdentifiers: [String] = []
) {
guard (prereleaseIdentifiers + buildMetadataIdentifiers).allSatisfyValidSemverIdentifier else {
guard (prereleaseIdentifiers + buildMetadataIdentifiers).allSatisfy({ $0.allSatisfy(\.isValidInSemverIdentifier) }) else {
fatalError("Invalid character found in semver identifier, must match [A-Za-z0-9-]")
}
guard prereleaseIdentifiers.allSatisfy({ $0.isValidSemverPrereleaseIdentifier }) else {
fatalError("Invalid prerelease identifier found, must be alphanumeric, exactly 0, or not start with 0.")
}
self.major = major
self.minor = minor
self.patch = patch
Expand All @@ -74,22 +77,19 @@ public struct SemanticVersion: Sendable, Hashable {
/// - TODO: Possibly throw more specific validation errors? Would this be useful?
///
/// - TODO: This code, while it does check validity better than what was here before, is ugly as heck. Clean it up.
public init?(string: String) {
guard string.allSatisfy({ $0.isASCII }) else { return nil }
public init?(string: some StringProtocol) {
guard string.allSatisfy(\.isASCII) else { return nil }

var idx = string.startIndex
func readNumber(usingIdx idx: inout String.Index) -> UInt? {
let startIdx = idx
while idx < string.endIndex, string[idx].isNumber { string.formIndex(after: &idx) }
let endIdx = idx
return string.distance(from: startIdx, to: endIdx) > 0 ? UInt(string[startIdx..<endIdx]) : nil
idx = string[idx...].firstIndex(where: { !$0.isWholeNumber }) ?? string.endIndex
return UInt(string[startIdx ..< idx])
}
func readIdent(usingIdx idx: inout String.Index) -> String? {
let startIdx = idx
while idx < string.endIndex, string[idx].isValidInSemverIdentifier { string.formIndex(after: &idx) }
let endIdx = idx
guard string.distance(from: startIdx, to: endIdx) > 0 else { return nil }
return String(string[startIdx..<endIdx])
idx = string[idx...].firstIndex(where: { !$0.isValidInSemverIdentifier }) ?? string.endIndex
return idx > startIdx ? String(string[startIdx ..< idx]) : nil
}

guard let major = readNumber(usingIdx: &idx) else { return nil }
Expand Down Expand Up @@ -119,8 +119,14 @@ public struct SemanticVersion: Sendable, Hashable {
}
string.formIndex(after: &idx)
guard let ident = readIdent(usingIdx: &idx) else { return nil }
if seenPlus { buildMetadataIdentifiers.append(ident) }
else { prereleaseIdentifiers.append(ident) }
if seenPlus {
buildMetadataIdentifiers.append(ident)
} else {
guard ident.isValidSemverPrereleaseIdentifier else {
return nil
}
prereleaseIdentifiers.append(ident)
}
}

self.major = major
Expand All @@ -132,7 +138,7 @@ public struct SemanticVersion: Sendable, Hashable {
}

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

switch zip(lhs.prereleaseIdentifiers, rhs.prereleaseIdentifiers)
.first(where: { $0 != $1 })
.map({ ((Int($0) ?? $0) as Any, (Int($1) ?? $1) as Any) })
.first(where: { $0 != $1 })
.map({ ((Int($0) ?? $0) as Any, (Int($1) ?? $1) as Any) })
{
case let .some((lId as Int, rId as Int)): return lId < rId
case let .some((lId as String, rId as String)): return lId < rId
Expand All @@ -160,32 +166,32 @@ extension SemanticVersion: Comparable {
}

extension SemanticVersion: LosslessStringConvertible {
/// See ``CustomStringConvertible/description``. An additional API guarantee is made that this property will always
/// yield a string which is correctly formatted as a valid semantic version number.
/// An additional API guarantee is made by this type that this property will always yield a string
/// which is correctly formatted as a valid semantic version number.
public var description: String {
return """
\(major).\
\(minor).\
\(patch)\
\(prereleaseIdentifiers.joined(separator: ".", prefix: "-"))\
\(buildMetadataIdentifiers.joined(separator: ".", prefix: "+"))
"""
"""
\(self.major).\
\(self.minor).\
\(self.patch)\
\(self.prereleaseIdentifiers.joined(separator: ".", prefix: "-"))\
\(self.buildMetadataIdentifiers.joined(separator: ".", prefix: "+"))
"""
}

/// See ``LosslessStringConvertible/init(_:)``. The semantics are identical to those of ``init?(string:)``.
// See `LosslessStringConvertible.init(_:)`. Identical semantics to ``init?(string:)``.
public init?(_ description: String) {
self.init(string: description)
}
}

extension SemanticVersion: Codable {
/// See ``Encodable/encode(to:)``.
public func encode(to encoder: Encoder) throws {
// See `Encodable.encode(to:)`.
public func encode(to encoder: any Encoder) throws {
try self.description.encode(to: encoder)
}

/// See ``Decodable/init(from:)``.
public init(from decoder: Decoder) throws {
// See `Decodable.init(from:)`.
public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let raw = try container.decode(String.self)

Expand All @@ -196,46 +202,56 @@ extension SemanticVersion: Codable {
}
}

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

fileprivate extension Character {
/// Valid characters in a semver identifier are defined by these BNF rules:
/// Valid characters in a semver identifier are defined by these BNF rules,
/// taken directly from [the SemVer BNF grammar][semver2bnf]:
///
/// [semver2bnf]: https://semver.org/spec/v2.0.0.html#backusnaur-form-grammar-for-valid-semver-versions
///
/// ```bnf
/// <identifier character> ::= <digit>
/// | <non-digit>
///
/// <non-digit> ::= <letter>
/// | "-"
///
/// <digit> ::= "0"
/// | <positive digit>
///
/// <identifier character> ::= <digit>
/// | <non-digit>
/// <non-digit> ::= <letter>
/// | "-"
/// <digit> ::= "0"
/// | <positive digit>
/// <positive digit> ::= "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
/// <letter> ::= "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J"
/// | "K" | "L" | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T"
/// | "U" | "V" | "W" | "X" | "Y" | "Z" | "a" | "b" | "c" | "d"
/// | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n"
/// | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x"
/// | "y" | "z"
/// <positive digit> ::= "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
///
/// <letter> ::= "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J"
/// | "K" | "L" | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T"
/// | "U" | "V" | "W" | "X" | "Y" | "Z" | "a" | "b" | "c" | "d"
/// | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n"
/// | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x"
/// | "y" | "z"
/// ```
var isValidInSemverIdentifier: Bool {
self.isASCII && (self.isLetter || self.isNumber || self == "-")
self.isLetter || self.isWholeNumber || self == "-"
}
}

fileprivate extension StringProtocol {
/// See ``Character/isValidInSemverIdentifier`` for validity rules.
var isValidSemverIdentifier: Bool {
return self.allSatisfy { $0.isValidInSemverIdentifier }
}
}

fileprivate extension Collection where Element: StringProtocol {
var allSatisfyValidSemverIdentifier: Bool {
return self.allSatisfy { $0.isValidSemverIdentifier }
/// A valid prerelease identifier must either:
///
/// - Be exactly "0",
/// - Not start with "0", or
/// - Contain non-numeric characters
///
///
var isValidSemverPrereleaseIdentifier: Bool {
self == "0" ||
!self.starts(with: "0") ||
self.contains { !$0.isWholeNumber }
}
}
9 changes: 8 additions & 1 deletion Tests/SwiftSemverTests/SwiftSemverTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,15 @@ final class SwiftSemverTests: XCTestCase {
XCTAssertEqual(SemanticVersion("1.2.3-1.dev"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["1", "dev"]))
XCTAssertEqual(SemanticVersion("1.2.3-dev-a"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["dev-a"]))
XCTAssertEqual(SemanticVersion("1.2.3--dev"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["-dev"]))
XCTAssertEqual(SemanticVersion("1.2.3-dev.0"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["dev", "0"]))
XCTAssertEqual(SemanticVersion("1.2.3-dev.1"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["dev", "1"]))
XCTAssertEqual(SemanticVersion("1.2.3-0"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["0"]))
XCTAssertEqual(SemanticVersion("1.2.3-1"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: ["1"]))

for badStr in [
"1.2.3-!dev", "1.2.3-dev!", "1.2.3-d!ev", // non-letter characters
"1.2.3-dev-!1", "1.2.3-dev-1!", "1.2.3-dev.!",
"1.2.3-dev-!1", "1.2.3-dev-1!", "1.2.3-dev.!", // more non-letter characters
"1.2.3-dev.01", "1.2.3-dev.00", // non-zero numeric starting with 0
] {
XCTAssertNil(SemanticVersion("\(badStr)"))
XCTAssertNil(SemanticVersion("\(badStr)+b"))
Expand All @@ -102,6 +107,8 @@ final class SwiftSemverTests: XCTestCase {
XCTAssertEqual(SemanticVersion("1.2.3+1.dev"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: [], buildMetadataIdentifiers: ["1", "dev"]))
XCTAssertEqual(SemanticVersion("1.2.3+dev-a"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: [], buildMetadataIdentifiers: ["dev-a"]))
XCTAssertEqual(SemanticVersion("1.2.3+-dev"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: [], buildMetadataIdentifiers: ["-dev"]))
XCTAssertEqual(SemanticVersion("1.2.3+dev.0"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: [], buildMetadataIdentifiers: ["dev", "0"]))
XCTAssertEqual(SemanticVersion("1.2.3+dev.01"), SemanticVersion(1, 2, 3, prereleaseIdentifiers: [], buildMetadataIdentifiers: ["dev", "01"]))

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

0 comments on commit fa2e77b

Please sign in to comment.