Files
pkl/stdlib/semver.pkl
2025-11-03 12:26:58 -08:00

229 lines
9.9 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//===----------------------------------------------------------------------===//
// Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
/// Parsing, comparison, and manipulation of [semantic version](https://semver.org/spec/v2.0.0.html) numbers.
@ModuleInfo { minPklVersion = "0.31.0" }
module pkl.semver
/// Tells whether [version] is a valid semantic version number.
function isValid(version: String) = parseOrNull(version) != null
/// Parses [version] as a semantic version number.
///
/// Throws if [version] is not a valid semantic version number.
///
/// Examples:
/// ```
/// semver.Version("1.2.3")
/// semver.Version("1.2.3-alpha")
/// semver.Version("1.2.3+456")
/// semver.Version("1.2.3-alpha+456")
/// ```
function Version(version: String): Version =
parseOrNull(version) ?? throw("`\(version)` is not a valid semantic version number.")
/// Parses [version] as a semantic version number.
///
/// Returns [null] if [version] is not a valid semantic version number.
///
/// Facts:
/// ```
/// semver.parseOrNull("1.2.3") == semver.Version("1.2.3")
/// semver.parseOrNull("1.2.3-alpha+456") == semver.Version("1.2.3-alpha+456")
/// semver.parseOrNull("1") == null
/// ```
function parseOrNull(version: String): Version? =
let (groups = versionRegex.matchEntire(version)?.groups)
if (groups == null)
null
else
new Version {
major = groups[1].value.toInt()
minor = groups[2].value.toInt()
patch = groups[3].value.toInt()
preRelease = groups[4]?.value
build = groups[5]?.value
}
/// A version comparator for use with methods such as [List.minWith()].
comparator: (Version, Version) -> Boolean = (v1: Version, v2: Version) -> v1.isLessThan(v2)
/// A [semantic version](https://semver.org/spec/v2.0.0.html).
///
/// To test if two versions are equal according to semantic versioning rules, use [equals()] instead of `==`.
class Version {
/// Major version zero (0.y.z) is for initial development.
/// Anything MAY change at any time.
/// The public API SHOULD NOT be considered stable.
///
/// Version 1.0.0 defines the public API.
/// The way in which the version number is incremented after this release is dependent on this public API and how it changes.
///
/// Major version X (X.y.z | X > 0) MUST be incremented if any backwards incompatible changes are introduced to the public API.
/// It MAY also include minor and patch level changes.
/// Patch and minor version MUST be reset to 0 when major version is incremented.
major: UInt
/// Minor version Y (x.Y.z | x > 0) MUST be incremented if new, backwards compatible functionality is introduced to the public API.
/// It MUST be incremented if any public API functionality is marked as deprecated.
/// It MAY be incremented if substantial new functionality or improvements are introduced within the private code.
/// It MAY include patch level changes.
/// Patch version MUST be reset to 0 when minor version is incremented.
minor: UInt
/// Patch version Z (x.y.Z | x > 0) MUST be incremented if only backwards compatible bug fixes are introduced.
/// A bug fix is defined as an internal change that fixes incorrect behavior.
patch: UInt
/// A pre-release version MAY be denoted by appending a hyphen and a series of dot separated identifiers immediately following the patch version.
/// Identifiers MUST comprise only ASCII alphanumerics and hyphens `[0-9A-Za-z-]`.
/// Identifiers MUST NOT be empty. Numeric identifiers MUST NOT include leading zeroes.
/// Pre-release versions have a lower precedence than the associated normal version.
/// A pre-release version indicates that the version is unstable and might not satisfy the intended compatibility requirements as denoted by its associated normal version.
///
/// Examples:
/// - `"1.0.0-alpha"`
/// - `"1.0.0-alpha.1"`
/// - `"1.0.0-0.3.7"`
/// - `"1.0.0-x.7.z.92"`
/// - `"1.0.0-x-y-z."`
preRelease: String(
matches(
Regex(
#"(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*"#,
),
),
)?
hidden fixed preReleaseIdentifiers: List<Int | String> =
if (preRelease == null) List() else preRelease.split(".").map((it) -> it.toIntOrNull() ?? it)
/// Build metadata MAY be denoted by appending a plus sign and a series of dot separated identifiers immediately following the patch or pre-release version.
/// Identifiers MUST comprise only ASCII alphanumerics and hyphens `[0-9A-Za-z-]`.
/// Identifiers MUST NOT be empty.
/// Build metadata MUST be ignored when determining version precedence.
/// Thus two versions that differ only in the build metadata, have the same precedence.
///
/// Examples:
/// - `"1.0.0-alpha+001"
/// - `"1.0.0+20130313144700"`
/// - `"1.0.0-beta+exp.sha.5114f85"`
/// - `"1.0.0+21AF26D3—-117B344092BD"`
///
/// Note: Unlike `==`, [equals()] and comparison methods such as [isLessThan()] ignore [build].
build: String(matches(Regex(#"[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*"#)))?
hidden fixed buildIdentifiers: List<String> = if (build == null) List() else build.split(".")
/// Tells whether this version is equal to [other] according to semantic versioning rules.
///
/// Facts:
/// ```
/// semver.Version("1.0.0").equals(semver.Version("1.0.0"))
/// !(semver.Version("1.0.0").equals(semver.Version("1.0.1")))
/// semver.Version("1.0.0-alpha+001").equals(semver.Version("1.0.0-alpha+999"))
/// ```
///
/// Note: `version1.equals(version2)` differs from `version1 == version2` in that it ignores [build].
function equals(other: Version): Boolean =
major == other.major
&& minor == other.minor
&& patch == other.patch
&& preRelease == other.preRelease
/// Tells whether this version is less than [other] according to semantic versioning rules.
///
/// Facts:
/// ```
/// semver.Version("1.0.0").isLessThan(semver.Version("2.0.0"))
/// semver.Version("2.0.0").isLessThan(semver.Version("2.1.0"))
/// semver.Version("2.1.0").isLessThan(semver.Version("2.1.1"))
///
/// semver.Version("1.0.0-alpha").isLessThan("1.0.0")
///
/// semver.Version("1.0.0-alpha").isLessThan(semver.Version("1.0.0-alpha.1"))
/// semver.Version("1.0.0-alpha.1").isLessThan(semver.Version("1.0.0-alpha.beta"))
/// semver.Version("1.0.0-alpha.beta").isLessThan(semver.Version("1.0.0-beta"))
/// semver.Version("1.0.0-beta").isLessThan(semver.Version("1.0.0-beta.2"))
/// semver.Version("1.0.0-beta.2").isLessThan(semver.Version("1.0.0-beta.11"))
/// semver.Version("1.0.0-beta.11").isLessThan(semver.Version("1.0.0-rc.1"))
/// semver.Version("1.0.0-rc.1").isLessThan(semver.Version("1.0.0"))
/// ```
function isLessThan(other: Version): Boolean =
major < other.major
|| major == other.major && minor < other.minor
|| major == other.major && minor == other.minor && patch < other.patch
|| major == other.major
&& minor == other.minor
&& patch == other.patch
&& isPreReleaseLessThan(other)
/// Tells whether this version is less than or equal to [other] according to semantic versioning rules.
function isLessThanOrEquals(other: Version): Boolean = isLessThan(other) || equals(other)
/// Tells whether this version is greater than [other] according to semantic versioning rules.
function isGreaterThan(other: Version): Boolean = other.isLessThan(this)
/// Tells whether this version is greater than or equal to [other] according to semantic versioning rules.
function isGreaterThanOrEquals(other: Version): Boolean = other.isLessThanOrEquals(this)
/// A normal version number MUST take the form X.Y.Z where X, Y, and Z are non-negative integers, and MUST NOT contain leading zeroes.
function isNormal(): Boolean = preRelease == null && build == null
/// Tells if this version has a non-zero [major] and no [preRelease].
function isStable(): Boolean = major > 0 && preRelease == null
/// Strips [preRelease] and [build] from this version.
function toNormal(): Version = (this) { preRelease = null; build = null }
function toString() =
"\(major).\(minor).\(patch)\(if (preRelease != null) "-\(preRelease)" else "")\(if (build != null) "+\(build)" else "")"
local function isPreReleaseLessThan(other: Version): Boolean =
if (preRelease == null)
false
else if (other.preRelease == null)
true
else if (preRelease == other.preRelease)
false
else
let (
result =
preReleaseIdentifiers
.zip(other.preReleaseIdentifiers)
.fold(null, (result, next) ->
if (result != null)
result
else if (next.first == next.second)
null
else if (next.first.getClass() == next.second.getClass())
next.first < next.second
else
next.first is Int
)
)
if (result != null)
result
else
preReleaseIdentifiers.length < other.preReleaseIdentifiers.length
}
// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
local versionRegex =
Regex(
#"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"#,
)