// std/semver — Semantic-version helpers used by release tooling, bump
// orchestrators, and any harness that needs to parse, compare, or emit
// SemVer (https://semver.org/) version strings.
//
// Scope: parse canonical "MAJOR.MINOR.PATCH" triples (no leading zeroes or
// prerelease/build metadata), strip/add the canonical leading `v`, compare
// release tags, compute single-step bumps, classify which bump separates two
// versions, and unpack release branches / tags.
// Keep release-tooling repos on this stdlib implementation so SemVer parsing,
// bump detection, and error messages do not drift between repos.
//
// Import with:
// import {
// Version, BumpKind, strip_v, add_v, is_v_semver, parse,
// max_canonical_tag, next, bump_type,
// version_from_release_branch, version_from_tag,
// } from "std/semver"
/** Parsed "MAJOR.MINOR.PATCH" triple. All three fields are non-negative ints. */
pub type Version = {major: int, minor: int, patch: int}
/** One canonical semantic-version increment. */
pub type BumpKind = "major" | "minor" | "patch"
/**
* Strip an optional leading `v` from a version string. Returns the empty
* string for `nil` input so callers can `strip_v(maybe_tag)` without a
* separate nil guard.
*
* @effects: []
* @errors: []
* @example: strip_v("v1.2.3")
*/
pub fn strip_v(version: string?) -> string {
const text = version ?? ""
if starts_with(text, "v") {
return substring(text, 1, len(text))
}
return text
}
/**
* Add a leading `v` to a bare semver string. No-op if already prefixed.
* Returns the empty string for `nil` input.
*
* @effects: []
* @errors: []
* @example: add_v("1.2.3")
*/
pub fn add_v(version: string?) -> string {
const text = version ?? ""
if text == "" || starts_with(text, "v") {
return text
}
return "v" + text
}
/**
* Return true for canonical release tags like `v1.2.3`. Rejects prerelease
* tails (`v1.2.3-rc.1`) and build metadata (`v1.2.3+ci.42`) — release
* tooling that cares about those forms should match its own regex.
*
* @effects: []
* @errors: []
* @example: is_v_semver("v1.2.3")
*/
pub fn is_v_semver(value: string?) -> bool {
const text = value ?? ""
return starts_with(text, "v") && parse(text) != nil
}
/**
* Parse a canonical "MAJOR.MINOR.PATCH" string into a `Version` dict. Accepts
* an optional leading `v`. Each component is either `0` or begins with a
* non-zero digit. Returns `nil` (not an exception) for non-canonical input or
* components outside the integer range, so callers can match-on-nil for soft
* validation. Use `next` / `bump_type` when a throwing variant is more
* ergonomic.
*
* @effects: []
* @errors: []
* @example: parse("v1.2.3")
*/
pub fn parse(version: string?) -> Version? {
const text = strip_v(version)
if regex_match("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", text) == nil {
return nil
}
const parts = split(text, ".")
const major = to_int(parts[0])
const minor = to_int(parts[1])
const patch = to_int(parts[2])
if major == nil || minor == nil || patch == nil {
return nil
}
return {major: major, minor: minor, patch: patch}
}
/**
* Return the numerically greatest canonical `vX.Y.Z` release tag. Input order
* does not affect the result. Bare versions, malformed tags, leading-zero
* components, prereleases, build metadata, and integer overflow are ignored.
* Returns `nil` when no canonical tag remains.
*
* @effects: []
* @errors: []
* @example: max_canonical_tag(["v2.9.0", "v2.10.0"])
*/
pub fn max_canonical_tag(tags: list<string>) -> string? {
let best_tag: string? = nil
let best_major = -1
let best_minor = -1
let best_patch = -1
for tag in tags {
const parsed = parse(tag)
if parsed == nil || !starts_with(tag, "v") {
continue
}
if parsed.major > best_major
|| (parsed.major == best_major && parsed.minor > best_minor)
|| (parsed.major == best_major
&& parsed
.minor
== best_minor
&& parsed.patch > best_patch) {
best_tag = tag
best_major = parsed.major
best_minor = parsed.minor
best_patch = parsed.patch
}
}
return best_tag
}
/**
* Return the next "MAJOR.MINOR.PATCH" version for a `"major"`, `"minor"`,
* or `"patch"` bump. Throws on non-semver `current` or unknown `bump`.
* The returned string never carries a leading `v` — wrap with `add_v` if
* the caller wants the tag-shaped form.
*
* @effects: []
* @errors: ["std/semver: current version is not semver", "std/semver: bump must be major, minor, or patch"]
* @example: next("1.2.3", "minor")
*/
pub fn next(current: string, bump: BumpKind) -> string {
const parsed = parse(current)
if parsed == nil {
throw "std/semver: current version is not semver: " + current
}
if bump == "major" {
return to_string(parsed.major + 1) + ".0.0"
}
if bump == "minor" {
return to_string(parsed.major) + "." + to_string(parsed.minor + 1) + ".0"
}
if bump == "patch" {
return to_string(parsed.major) + "." + to_string(parsed.minor) + "."
+ to_string(parsed.patch + 1)
}
throw "std/semver: bump must be major, minor, or patch, got: ${bump}"
}
/**
* Classify the single step that takes `current` to `target`. Returns
* `"major"`, `"minor"`, `"patch"`, or `nil` when the gap is not exactly
* one bump (downgrades, two-step jumps, or equal versions all yield
* `nil`). Throws when either side is not parseable as semver — that's a
* programmer-error condition, not user input drift.
*
* @effects: []
* @errors: ["std/semver: current/target version is not semver"]
* @example: bump_type("1.2.3", "1.2.4")
*/
pub fn bump_type(current: string, target: string) -> BumpKind? {
const cur = parse(current)
const tgt = parse(target)
if cur == nil {
throw "std/semver: current version is not semver: " + current
}
if tgt == nil {
throw "std/semver: target version is not semver: " + target
}
if tgt.major == cur.major + 1 && tgt.minor == 0 && tgt.patch == 0 {
return "major"
}
if tgt.major == cur.major && tgt.minor == cur.minor + 1 && tgt.patch == 0 {
return "minor"
}
if tgt.major == cur.major && tgt.minor == cur.minor && tgt.patch == cur.patch + 1 {
return "patch"
}
return nil
}
/**
* Extract `X.Y.Z` from a release branch named `release/vX.Y.Z`. Returns
* the empty string for branches that don't match the canonical shape —
* including `release/X.Y.Z` without the `v` and `release/v1.2` with a
* truncated triple. Use the empty-string sentinel to gate
* release-only code paths.
*
* @effects: []
* @errors: []
* @example: version_from_release_branch("release/v1.2.3")
*/
pub fn version_from_release_branch(branch: string?) -> string {
const text = branch ?? ""
if !starts_with(text, "release/v") {
return ""
}
const candidate = substring(text, 9, len(text))
if starts_with(candidate, "v") || parse(candidate) == nil {
return ""
}
return candidate
}
/**
* Extract `X.Y.Z` from a `vX.Y.Z` tag. Non-`v` tags pass through unchanged
* (this is just `strip_v` named in a domain-specific way so callers reading
* release pipelines can grep for it).
*
* @effects: []
* @errors: []
* @example: version_from_tag("v1.2.3")
*/
pub fn version_from_tag(tag: string?) -> string {
return strip_v(tag)
}