Skip to content

Commit be032d2

Browse files
add a throwing initialiser for creating a version from a string
This new initialiser throws a `VersionError` instance when initialisation fails. This gives the user more information and control over error handling. `Version`'s conformance to `LosslessStringConvertible` is preserved by having `init?(_ versionString: String)` call this new initialiser, and return `nil` when an error is thrown.
1 parent f717de4 commit be032d2

File tree

2 files changed

+547
-46
lines changed

2 files changed

+547
-46
lines changed

Sources/TSCUtility/Version.swift

Lines changed: 106 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public struct Version {
2828
/// The build metadata.
2929
public let buildMetadataIdentifiers: [String]
3030

31-
/// Create a version object.
31+
/// Creates a version object.
3232
public init(
3333
_ major: Int,
3434
_ minor: Int,
@@ -45,6 +45,110 @@ public struct Version {
4545
}
4646
}
4747

48+
/// An error that occurs during the creation of a version.
49+
public enum VersionError: Error, CustomStringConvertible {
50+
/// The version string contains non-ASCII characters.
51+
case nonASCIIVersionString(_ versionString: String)
52+
/// The version core contains an invalid number of Identifiers.
53+
case invalidVersionCoreIdentifiersCount(_ identifiers: [String])
54+
/// Some or all of the version core identifiers contain non-numerical characters or are empty.
55+
case nonNuumericalOrEmptyVersionCoreIdentifiers(_ identifiers: [String])
56+
/// Some or all of the pre-release identifiers contain characters other than alpha-numerics and hyphens.
57+
case nonAlphaNuumerHyphenalPrereleaseIdentifiers(_ identifiers: [String])
58+
/// Some or all of the build metadata identifiers contain characters other than alpha-numerics and hyphens.
59+
case nonAlphaNuumerHyphenalBuildMetadataIdentifiers(_ identifiers: [String])
60+
61+
public var description: String {
62+
switch self {
63+
case let .nonASCIIVersionString(versionString):
64+
return "non-ASCII characters in version string '\(versionString)'"
65+
case let .invalidVersionCoreIdentifiersCount(identifiers):
66+
return "\(identifiers.count < 3 ? "fewer" : "more") than 3 identifiers in version core '\(identifiers.joined(separator: "."))'"
67+
case let .nonNuumericalOrEmptyVersionCoreIdentifiers(identifiers):
68+
if !identifiers.allSatisfy( { !$0.isEmpty } ) {
69+
return "empty identifiers in version core '\(identifiers.joined(separator: "."))'"
70+
} else {
71+
// Not checking for `.isASCII` here because non-ASCII characters should've already been caught before this.
72+
let nonNumericalIdentifiers = identifiers.filter { !$0.allSatisfy(\.isNumber) }
73+
return "non-numerical characters in version core identifier\(nonNumericalIdentifiers.count > 1 ? "s" : "") \(nonNumericalIdentifiers.map { "'\($0)'" } .joined(separator: ", "))"
74+
}
75+
case let .nonAlphaNuumerHyphenalPrereleaseIdentifiers(identifiers):
76+
// Not checking for `.isASCII` here because non-ASCII characters should've already been caught before this.
77+
let nonAlhpaNumericalIdentifiers = identifiers.filter { !$0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } }
78+
return "characters other than alpha-numerics and hyphens in pre-release identifier\(nonAlhpaNumericalIdentifiers.count > 1 ? "s" : "") \(nonAlhpaNumericalIdentifiers.map { "'\($0)'" } .joined(separator: ", "))"
79+
case let .nonAlphaNuumerHyphenalBuildMetadataIdentifiers(identifiers):
80+
// Not checking for `.isASCII` here because non-ASCII characters should've already been caught before this.
81+
let nonAlhpaNumericalIdentifiers = identifiers.filter { !$0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } }
82+
return "characters other than alpha-numerics and hyphens in build metadata identifier\(nonAlhpaNumericalIdentifiers.count > 1 ? "s" : "") \(nonAlhpaNumericalIdentifiers.map { "'\($0)'" } .joined(separator: ", "))"
83+
}
84+
}
85+
}
86+
87+
extension Version {
88+
// TODO: Rename this function to `init(string: String) trhows`, after `init?(string: String)` is removed.
89+
// TODO: Find a better error-checking order.
90+
// Currently, if a version string is "fourty-two", this initializer throws an error that says "fourty" is only 1 version core identifier, which is not enough.
91+
// But this is misleading the user to consider "fourty" as a valid version core identifier.
92+
// We should find a way to check for (or throw) "wrong characters used" errors first, but without overly-complicating the logic.
93+
/// Creates a version from the given string.
94+
/// - Parameter versionString: The string to create the version from.
95+
/// - Throws: A `VersionError` instance if the `versionString` doesn't follow [SemVer 2.0.0](https://semver.org).
96+
public init(versionString: String) throws {
97+
// 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.)
98+
// Alphanumerics check will come later, after each identifier is split out (i.e. after the delimiters are removed).
99+
guard versionString.allSatisfy(\.isASCII) else {
100+
throw VersionError.nonASCIIVersionString(versionString)
101+
}
102+
103+
let metadataDelimiterIndex = versionString.firstIndex(of: "+")
104+
// SemVer 2.0.0 requires that pre-release identifiers come before build metadata identifiers
105+
let prereleaseDelimiterIndex = versionString[..<(metadataDelimiterIndex ?? versionString.endIndex)].firstIndex(of: "-")
106+
107+
let versionCore = versionString[..<(prereleaseDelimiterIndex ?? metadataDelimiterIndex ?? versionString.endIndex)]
108+
let versionCoreIdentifiers = versionCore.split(separator: ".", omittingEmptySubsequences: false)
109+
110+
guard versionCoreIdentifiers.count == 3 else {
111+
throw VersionError.invalidVersionCoreIdentifiersCount(versionCoreIdentifiers.map { String($0) })
112+
}
113+
114+
guard
115+
// Major, minor, and patch versions must be ASCII numbers, according to the semantic versioning standard.
116+
// Converting each identifier from a substring to an integer doubles as checking if the identifiers have non-numeric characters.
117+
let major = Int(versionCoreIdentifiers[0]),
118+
let minor = Int(versionCoreIdentifiers[1]),
119+
let patch = Int(versionCoreIdentifiers[2])
120+
else {
121+
throw VersionError.nonNuumericalOrEmptyVersionCoreIdentifiers(versionCoreIdentifiers.map { String($0) })
122+
}
123+
124+
self.major = major
125+
self.minor = minor
126+
self.patch = patch
127+
128+
if let prereleaseDelimiterIndex = prereleaseDelimiterIndex {
129+
let prereleaseStartIndex = versionString.index(after: prereleaseDelimiterIndex)
130+
let prereleaseIdentifiers = versionString[prereleaseStartIndex..<(metadataDelimiterIndex ?? versionString.endIndex)].split(separator: ".", omittingEmptySubsequences: false)
131+
guard prereleaseIdentifiers.allSatisfy( { $0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } } ) else {
132+
throw VersionError.nonAlphaNuumerHyphenalPrereleaseIdentifiers(prereleaseIdentifiers.map { String($0) })
133+
}
134+
self.prereleaseIdentifiers = prereleaseIdentifiers.map { String($0) }
135+
} else {
136+
self.prereleaseIdentifiers = []
137+
}
138+
139+
if let metadataDelimiterIndex = metadataDelimiterIndex {
140+
let metadataStartIndex = versionString.index(after: metadataDelimiterIndex)
141+
let buildMetadataIdentifiers = versionString[metadataStartIndex...].split(separator: ".", omittingEmptySubsequences: false)
142+
guard buildMetadataIdentifiers.allSatisfy( { $0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } } ) else {
143+
throw VersionError.nonAlphaNuumerHyphenalBuildMetadataIdentifiers(buildMetadataIdentifiers.map { String($0) })
144+
}
145+
self.buildMetadataIdentifiers = buildMetadataIdentifiers.map { String($0) }
146+
} else {
147+
self.buildMetadataIdentifiers = []
148+
}
149+
}
150+
}
151+
48152
extension Version: Comparable, Hashable {
49153

50154
func isEqualWithoutPrerelease(_ other: Version) -> Bool {
@@ -122,47 +226,7 @@ extension Version: LosslessStringConvertible {
122226
/// Initializes a version struct with the provided version string.
123227
/// - Parameter version: A version string to use for creating a new version struct.
124228
public init?(_ versionString: String) {
125-
// 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.)
126-
// Alphanumerics check will come later, after each identifier is split out (i.e. after the delimiters are removed).
127-
guard versionString.allSatisfy(\.isASCII) else { return nil }
128-
129-
let metadataDelimiterIndex = versionString.firstIndex(of: "+")
130-
// SemVer 2.0.0 requires that pre-release identifiers come before build metadata identifiers
131-
let prereleaseDelimiterIndex = versionString[..<(metadataDelimiterIndex ?? versionString.endIndex)].firstIndex(of: "-")
132-
133-
let versionCore = versionString[..<(prereleaseDelimiterIndex ?? metadataDelimiterIndex ?? versionString.endIndex)]
134-
let versionCoreIdentifiers = versionCore.split(separator: ".", omittingEmptySubsequences: false)
135-
136-
guard
137-
versionCoreIdentifiers.count == 3,
138-
// Major, minor, and patch versions must be ASCII numbers, according to the semantic versioning standard.
139-
// Converting each identifier from a substring to an integer doubles as checking if the identifiers have non-numeric characters.
140-
let major = Int(versionCoreIdentifiers[0]),
141-
let minor = Int(versionCoreIdentifiers[1]),
142-
let patch = Int(versionCoreIdentifiers[2])
143-
else { return nil }
144-
145-
self.major = major
146-
self.minor = minor
147-
self.patch = patch
148-
149-
if let prereleaseDelimiterIndex = prereleaseDelimiterIndex {
150-
let prereleaseStartIndex = versionString.index(after: prereleaseDelimiterIndex)
151-
let prereleaseIdentifiers = versionString[prereleaseStartIndex..<(metadataDelimiterIndex ?? versionString.endIndex)].split(separator: ".", omittingEmptySubsequences: false)
152-
guard prereleaseIdentifiers.allSatisfy( { $0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } } ) else { return nil }
153-
self.prereleaseIdentifiers = prereleaseIdentifiers.map { String($0) }
154-
} else {
155-
self.prereleaseIdentifiers = []
156-
}
157-
158-
if let metadataDelimiterIndex = metadataDelimiterIndex {
159-
let metadataStartIndex = versionString.index(after: metadataDelimiterIndex)
160-
let buildMetadataIdentifiers = versionString[metadataStartIndex...].split(separator: ".", omittingEmptySubsequences: false)
161-
guard buildMetadataIdentifiers.allSatisfy( { $0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } } ) else { return nil }
162-
self.buildMetadataIdentifiers = buildMetadataIdentifiers.map { String($0) }
163-
} else {
164-
self.buildMetadataIdentifiers = []
165-
}
229+
try? self.init(versionString: versionString)
166230
}
167231
}
168232

0 commit comments

Comments
 (0)