//===----------------------------------------------------------------------===// // 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 = 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 = 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-]+)*))?"#, )