Files
purchases-ios/Sources/RulesEngine/CustomOperators/SemverOperator.swift
T
Antonio PallaresandCursor 2c5f57a83c Share one arity check across the strict operators (#7571)
Seven operators hand-wrote the same count check and the same message.
Move it to Operators, next to evalArgs, whose result it always checks.

Messages are unchanged, so no fixture moves.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 13:09:59 +02:00

173 lines
6.3 KiB
Swift

//
// SemverOperator.swift
//
// Created by Antonio Pallares.
//
import Foundation
/// Name used in this operator's error messages.
private let operatorName = "rc.semverCompare"
extension RulesEngine {
/// `rc.semverCompare` — three-way version comparison following
/// Semantic Versioning 2.0.0 precedence.
enum SemverOperator {
/// `{"rc.semverCompare": [left, right]}` — `-1`, `0`, or `1`, so rules
/// compare with the existing operators:
/// `{">=": [{"rc.semverCompare": [{"var": "appVersion"}, "2.1.0"]}, 0]}`.
///
/// The built-in `<` / `>` compare version strings lexicographically,
/// which puts `"10.0.0"` below `"9.0.0"`.
///
/// Both operands must be strings that parse, otherwise
/// `EvaluationError.typeMismatch`. Returning `0` for a malformed version
/// or an explicit null would report "equal", which satisfies a `>= 0`
/// gate and so matches every rule built on it. Arity is exact for the
/// same reason.
static func opSemverCompare(args: Value, vars: Scope) throws -> Value {
let evaluated = try Operators.evalArgs(args, vars: vars)
try Operators.checkArity(evaluated.count, allowed: [2], operatorName: operatorName)
let left = try SemanticVersion(parsing: evaluated[0])
let right = try SemanticVersion(parsing: evaluated[1])
return .int(Int64(left.compare(to: right)))
}
}
}
/// A parsed version: core components padded to three, plus prerelease
/// identifiers (empty for a release version). Build metadata is dropped, since
/// semver §10 excludes it from precedence.
///
/// Three deliberate concessions to real-world version strings: a missing minor
/// or patch is `0` (`"2.1"` ≡ `"2.1.0"`), leading zeros are accepted
/// (`"1.02.0"` ≡ `"1.2.0"`) even though the spec forbids them, and build
/// metadata is dropped without being validated.
private struct SemanticVersion {
private static let coreComponents = 3
let core: [Int]
let prerelease: [String]
init(parsing value: RulesEngine.Value) throws {
guard case .string(let string) = value else {
throw RulesEngine.EvaluationError.typeMismatch(
message: "operator '\(operatorName)' expected version strings, got \(value)"
)
}
let withoutBuild = string.prefix { $0 != "+" }
let coreText: Substring
let prereleaseText: Substring?
if let separator = withoutBuild.firstIndex(of: "-") {
coreText = withoutBuild[..<separator]
prereleaseText = withoutBuild[withoutBuild.index(after: separator)...]
} else {
coreText = withoutBuild
prereleaseText = nil
}
self.core = try Self.parseCore(coreText, in: string)
self.prerelease = try Self.parsePrerelease(prereleaseText, in: string)
}
func compare(to other: SemanticVersion) -> Int {
for (lhs, rhs) in zip(self.core, other.core) where lhs != rhs {
return lhs < rhs ? -1 : 1
}
switch (self.prerelease.isEmpty, other.prerelease.isEmpty) {
case (true, true):
return 0
case (true, false):
// A release outranks any prerelease of the same core.
return 1
case (false, true):
return -1
case (false, false):
return Self.compare(prerelease: self.prerelease, with: other.prerelease)
}
}
private static func parseCore(_ text: Substring, in original: String) throws -> [Int] {
let components = text.split(separator: ".", omittingEmptySubsequences: false)
guard !components.isEmpty, components.count <= Self.coreComponents else {
throw Self.invalid(original)
}
var core = try components.map { component -> Int in
guard let number = Self.numericIdentifier(component) else {
throw Self.invalid(original)
}
return number
}
core.append(contentsOf: repeatElement(0, count: Self.coreComponents - core.count))
return core
}
private static func parsePrerelease(
_ text: Substring?,
in original: String
) throws -> [String] {
guard let text else { return [] }
return try text.split(separator: ".", omittingEmptySubsequences: false).map { identifier in
guard !identifier.isEmpty, identifier.allSatisfy(Self.isIdentifierCharacter) else {
throw Self.invalid(original)
}
return String(identifier)
}
}
/// Semver §11: identifiers are compared field by field. Numeric fields
/// compare numerically, alphanumeric ones by ASCII order, and a numeric
/// field always ranks below an alphanumeric one. When every shared field
/// ties, the version with more fields wins.
private static func compare(prerelease lhs: [String], with rhs: [String]) -> Int {
for (left, right) in zip(lhs, rhs) {
switch (Self.numericIdentifier(left), Self.numericIdentifier(right)) {
case (.some(let left), .some(let right)) where left != right:
return left < right ? -1 : 1
case (.some, .none):
return -1
case (.none, .some):
return 1
case (.none, .none) where left != right:
return left < right ? -1 : 1
default:
continue
}
}
if lhs.count != rhs.count {
return lhs.count < rhs.count ? -1 : 1
}
return 0
}
/// A numeric identifier is digits only. `Int(_:)` alone would also accept a
/// signed identifier like `-1`, which is alphanumeric here.
private static func numericIdentifier<S: StringProtocol>(_ text: S) -> Int? {
guard !text.isEmpty, text.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil }
return Int(text)
}
private static func isIdentifierCharacter(_ character: Character) -> Bool {
character.isASCII && (character.isLetter || character.isNumber || character == "-")
}
private static func invalid(_ version: String) -> RulesEngine.EvaluationError {
.typeMismatch(
message: "operator '\(operatorName)' could not parse version '\(version)'"
)
}
}