Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TSCUtility] Correct semantic version parsing and comparison #214

183 changes: 141 additions & 42 deletions Sources/TSCUtility/Version.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors
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
Expand All @@ -11,7 +11,7 @@
import TSCBasic

/// A struct representing a semver version.
public struct Version: Hashable {
public struct Version {

/// The major version.
public let major: Int
Expand All @@ -28,7 +28,7 @@ public struct Version: Hashable {
/// The build metadata.
public let buildMetadataIdentifiers: [String]

/// Create a version object.
/// Creates a version object.
public init(
_ major: Int,
_ minor: Int,
Expand All @@ -45,12 +45,122 @@ public struct Version: Hashable {
}
}

extension Version: Comparable {
/// An error that occurs during the creation of a version.
public enum VersionError: Error, CustomStringConvertible {
/// The version string contains non-ASCII characters.
case nonASCIIVersionString(_ versionString: String)
/// The version core contains an invalid number of Identifiers.
case invalidVersionCoreIdentifiersCount(_ identifiers: [String])
/// Some or all of the version core identifiers contain non-numerical characters or are empty.
case nonNumericalOrEmptyVersionCoreIdentifiers(_ identifiers: [String])
/// Some or all of the pre-release identifiers contain characters other than alpha-numerics and hyphens.
case nonAlphaNumerHyphenalPrereleaseIdentifiers(_ identifiers: [String])
/// Some or all of the build metadata identifiers contain characters other than alpha-numerics and hyphens.
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):
return "\(identifiers.count < 3 ? "fewer" : "more") than 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: String) 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.
/// - Parameter versionString: The string to create the version from.
/// - Throws: A `VersionError` instance if the `versionString` doesn't follow [SemVer 2.0.0](https://semver.org).
public init(versionString: String) 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 else {
throw VersionError.invalidVersionCoreIdentifiersCount(versionCoreIdentifiers.map { String($0) })
}

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 = 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]
Expand All @@ -64,7 +174,7 @@ extension Version: Comparable {
}

guard rhs.prereleaseIdentifiers.count > 0 else {
return true // Prerelease lhs < non-prerelease rhs
return true // Prerelease lhs < non-prerelease rhs
}

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

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 {
Expand All @@ -103,47 +222,28 @@ extension Version: CustomStringConvertible {
}
}

public extension Version {
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.
///
/// - Parameters:
/// - string: The string to parse.
init?(string: String) {
let prereleaseStartIndex = string.firstIndex(of: "-")
let metadataStartIndex = string.firstIndex(of: "+")

let requiredEndIndex = prereleaseStartIndex ?? metadataStartIndex ?? string.endIndex
let requiredCharacters = string.prefix(upTo: requiredEndIndex)
let requiredComponents = requiredCharacters
.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
.map(String.init).compactMap({ Int($0) }).filter({ $0 >= 0 })

guard requiredComponents.count == 3 else { return nil }

self.major = requiredComponents[0]
self.minor = requiredComponents[1]
self.patch = requiredComponents[2]

func identifiers(start: String.Index?, end: String.Index) -> [String] {
guard let start = start else { return [] }
let identifiers = string[string.index(after: start)..<end]
return identifiers.split(separator: ".").map(String.init)
}

self.prereleaseIdentifiers = identifiers(
start: prereleaseStartIndex,
end: metadataStartIndex ?? string.endIndex)
self.buildMetadataIdentifiers = identifiers(
start: metadataStartIndex,
end: string.endIndex)
/// - 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(string: value) else {
guard let version = Version(value) else {
fatalError("\(value) is not a valid version")
}
self = version
Expand All @@ -163,7 +263,7 @@ extension Version: JSONMappable, JSONSerializable {
guard case .string(let string) = json else {
throw JSON.MapError.custom(key: nil, message: "expected string, got \(json)")
}
guard let version = Version(string: string) else {
guard let version = Version(string) else {
throw JSON.MapError.custom(key: nil, message: "Invalid version string \(string)")
}
self.init(version)
Expand Down Expand Up @@ -192,7 +292,7 @@ extension Version: Codable {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)

guard let version = Version(string: string) else {
guard let version = Version(string) else {
throw DecodingError.dataCorrupted(.init(
codingPath: decoder.codingPath,
debugDescription: "Invalid version string \(string)"))
Expand Down Expand Up @@ -232,11 +332,10 @@ extension Range where Bound == 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 ranage does not contain prerelease identifiers, return false.
// If the range does not contain prerelease identifiers, return false.
if lowerBound.prereleaseIdentifiers.isEmpty && upperBound.prereleaseIdentifiers.isEmpty {
return false
}
Expand Down
Loading