Skip to content

Commit a67dd6d

Browse files
Fix comparison issues
As implemented, there were a couple of problems where the library was not performing comparisons between semvers correctly. The first issue is that the presence of buildmetadata was causing the library to treat the version as unstable, which also meant that, given the comparison implementation, the buildmetadata was being considered when comparing two semvers. This is a violation of Section 10 of the semver.org spec, which specifies that “Build metadata MUST be ignored when determining version precedence”. Additionally, it is not specified by semver.org that that the presence of buildmetadata should be involved in determining the stability of a semver. The second problem is that the library uses simple ASCII lexical ordering to compare the pre-release string of two semvers, which does not match the rules defined in Section 11.4 of the semver specification. This change addresses these problems with the following changes 1) Remove the build.isEmpty check from the implementation of the isStable property 2) Implement a PreReleaseIdentifier enum and add Comparable conformance to an array of these types, per the rules laid out in Section 11.4 of the semver specification 3) Add a computed property that returns an array of PreReleaseIndentifiers by parsing the preRelease string 4) Update the less than operator implementation to apply the above changes appropriately 5) Add additional test cases to verify the new behavior Testing: 1) Existing unit tests pass 2) New unit tests pass
1 parent 45e2ec8 commit a67dd6d

File tree

2 files changed

+79
-12
lines changed

2 files changed

+79
-12
lines changed

Sources/SemanticVersion/SemanticVersion.swift

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ public struct SemanticVersion: Codable, Equatable, Hashable {
4343
self.preRelease = preRelease
4444
self.build = build
4545
}
46+
47+
public enum PreReleaseIdentifier: Equatable {
48+
case alphanumeric(String)
49+
case numeric(Int)
50+
}
4651
}
4752

4853

@@ -74,26 +79,81 @@ extension SemanticVersion: Comparable {
7479
if lhs.major != rhs.major { return lhs.major < rhs.major }
7580
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
7681
if lhs.patch != rhs.patch { return lhs.patch < rhs.patch }
77-
if lhs.preRelease != rhs.preRelease {
78-
// Ensure stable versions sort after their betas ...
79-
if lhs.isStable { return false }
80-
if rhs.isStable { return true }
81-
// ... otherwise sort by preRelease
82-
return lhs.preRelease < rhs.preRelease
83-
}
84-
// ... and build
85-
return lhs.build < rhs.build
82+
83+
// A stable release takes precedence over a pre-release
84+
if lhs.isStable != rhs.isStable { return rhs.isStable }
85+
86+
// Otherwise compare the pre-releases per section 11.4
87+
// See: https://semver.org/#spec-item-11
88+
return lhs.preReleaseIdentifiers < rhs.preReleaseIdentifiers
89+
90+
// Note that per section 10, buildmetadata MUST not be
91+
// considered when determining precedence
92+
// See: https://semver.org/#spec-item-10
8693
}
8794
}
8895

8996

9097
extension SemanticVersion {
91-
public var isStable: Bool { return preRelease.isEmpty && build.isEmpty }
98+
public var isStable: Bool { return preRelease.isEmpty }
9299
public var isPreRelease: Bool { return !isStable }
93100
public var isMajorRelease: Bool { return isStable && (major > 0 && minor == 0 && patch == 0) }
94101
public var isMinorRelease: Bool { return isStable && (minor > 0 && patch == 0) }
95102
public var isPatchRelease: Bool { return isStable && patch > 0 }
96103
public var isInitialRelease: Bool { return self == .init(0, 0, 0) }
104+
105+
public var preReleaseIdentifiers: [PreReleaseIdentifier] {
106+
return preRelease
107+
.split(separator: ".")
108+
.map { PreReleaseIdentifier(String($0)) }
109+
}
110+
}
111+
112+
extension SemanticVersion.PreReleaseIdentifier {
113+
init(_ rawValue: String) {
114+
if let number = Int(rawValue) {
115+
self = .numeric(number)
116+
} else {
117+
self = .alphanumeric(rawValue)
118+
}
119+
}
120+
}
121+
122+
extension SemanticVersion.PreReleaseIdentifier: Comparable {
123+
public static func < (lhs: Self, rhs: Self) -> Bool {
124+
// These rules are laid out in section 11.4 of the semver spec
125+
// See: https://semver.org/#spec-item-11
126+
switch (lhs, rhs) {
127+
case (.numeric, .alphanumeric):
128+
// 11.4.3 - Numeric identifiers always have lower precedence than non-numeric identifiers
129+
return true
130+
case (.alphanumeric, .numeric):
131+
// 11.4.3 - Numeric identifiers always have lower precedence than non-numeric identifiers
132+
return false
133+
case (.numeric(let lhInt), .numeric(let rhInt)):
134+
// 11.4.1 - Identifiers consisting of only digits are compared numerically
135+
return lhInt < rhInt
136+
case (.alphanumeric(let lhString), .alphanumeric(let rhString)):
137+
// 11.4.2 - Identifiers with letters or hyphens are compared lexically in ASCII sort order
138+
return lhString < rhString
139+
}
140+
}
141+
}
142+
143+
144+
extension Array: Comparable where Element == SemanticVersion.PreReleaseIdentifier {
145+
public static func < (lhs: Self, rhs: Self) -> Bool {
146+
// Per section 11.4 of the semver spec, compare left to right until a
147+
// difference is found.
148+
// See: https://semver.org/#spec-item-11
149+
for (lhIdentifier, rhIdentifier) in zip(lhs, rhs) {
150+
if lhIdentifier != rhIdentifier { return lhIdentifier < rhIdentifier }
151+
}
152+
153+
// 11.4.4 - A larger set of identifiers will have a higher precendence
154+
// than a smaller set, if all the preceding identifiers are equal.
155+
return lhs.count < rhs.count
156+
}
97157
}
98158

99159
#if swift(>=5.5)

Tests/SemanticVersionTests/SemanticVersionTests.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,8 @@ final class SemanticVersionTests: XCTestCase {
124124
XCTAssert(SemanticVersion(1, 0, 0) < SemanticVersion(1, 1, 0))
125125
XCTAssert(SemanticVersion(1, 0, 0) < SemanticVersion(1, 0, 1))
126126
XCTAssert(SemanticVersion(1, 0, 0, "a") < SemanticVersion(1, 0, 0, "b"))
127-
XCTAssert(SemanticVersion(1, 0, 0, "a", "a") < SemanticVersion(1, 0, 0, "a", "b"))
127+
XCTAssertLessThan(SemanticVersion(1, 0, 0, "alpha.2"), SemanticVersion(1, 0, 0, "alpha.11"))
128+
XCTAssertLessThan(SemanticVersion(1, 0, 0, "alpha.2"), SemanticVersion(1, 0, 0, "alpha.2.1"))
128129

129130
// ensure betas come before releases
130131
XCTAssert(SemanticVersion(1, 0, 0, "b1") < SemanticVersion(1, 0, 0))
@@ -133,14 +134,20 @@ final class SemanticVersionTests: XCTestCase {
133134
XCTAssert(SemanticVersion(1, 0, 0) < SemanticVersion(1, 0, 1, "b1"))
134135
// once the patch bumps up to the beta level again, it sorts higher
135136
XCTAssert(SemanticVersion(1, 0, 1) > SemanticVersion(1, 0, 1, "b1"))
137+
138+
// Ensure metadata is not considered
139+
XCTAssertFalse(SemanticVersion(1, 0, 0, "a", "a") < SemanticVersion(1, 0, 0, "a", "b"))
140+
XCTAssertFalse(SemanticVersion(1, 0, 0, "a", "a") > SemanticVersion(1, 0, 0, "a", "b"))
141+
XCTAssertLessThan(SemanticVersion(1, 0, 0, "alpha", "build1"), SemanticVersion(1, 0, 0, "", "build1"))
136142
}
137143

138144
func test_isStable() throws {
139145
XCTAssert(SemanticVersion(1, 0, 0).isStable)
140146
XCTAssert(SemanticVersion(1, 0, 0, "").isStable)
141147
XCTAssert(SemanticVersion(1, 0, 0, "", "").isStable)
142148
XCTAssertFalse(SemanticVersion(1, 0, 0, "a").isStable)
143-
XCTAssertFalse(SemanticVersion(1, 0, 0, "", "a").isStable)
149+
XCTAssertTrue(SemanticVersion(1, 0, 0, "", "a").isStable)
150+
XCTAssertFalse(SemanticVersion(1, 0, 0, "a", "b").isStable)
144151
}
145152

146153
func test_isMajorRelease() throws {

0 commit comments

Comments
 (0)