Ident type
pub type Ident = NumId{n: Int} | StrId{s: Str} derive Eq, ShowOne pre-release identifier: a number (compared numerically) or an alphanumeric word (compared in ASCII order). Numbers sort before words.
Package
Semantic Versioning 2.0.0: parse, compare, sort and bump versions, and match Cargo and npm version ranges
0.1.0 MIT
sspur add semver@^0.1.0or in sspur.toml:
semver = { version = "^0.1.0" }Semantic Versioning 2.0.0 for SSPUR: parse versions with pre-release and build metadata, compare them by SemVer precedence, sort, bump and diff them, and parse and match version ranges in both Cargo and npm (node-semver) syntax. Everything is pure: no effects, and every parse returns a Res with a typed reason instead of raising.
The tests include the version lists of semver.org and the range tables of node-semver and of Rust's semver crate, written out as literals.
use semver.{parse, parse_npm, parse_cargo, format_version, format_range, max_satisfying, sort_versions, bump_pre}
fn show(r: Res[semver.Version, semver.VersionErr]) -> Str
= match r
| ok(v) => format_version(v)
| err(e) => semver.describe_error(e)
fn main() -> Unit ! log
= do
vs = ["1.2.3", "1.10.0", "1.2.10-rc.1", "2.0.0", "1.9.0"].map(s => parse(s).or(semver.version(0, 0, 0)))
log(sort_versions(vs).map(format_version).join(" < "))
match parse_npm("^1.2 || >=3.0.0-rc.1")
| ok(r) => log("npm: {format_range(r)}, best {max_satisfying(vs, r).map(format_version).or("none")}")
| err(e) => log(e.reason)
match parse_cargo(">=1.2.3, <1.10")
| ok(r) => log("cargo: {format_range(r)}, best {max_satisfying(vs, r).map(format_version).or("none")}")
| err(e) => log(e.reason)
log(show(parse("1.2.3").map(v => bump_pre(v, "rc").or(v))))
log(show(parse("1.02.3")))
Output (the same natively and with --interp):
1.2.3 < 1.2.10-rc.1 < 1.9.0 < 1.10.0 < 2.0.0
npm: >=1.2.0 <2.0.0-0 || >=3.0.0-rc.1, best 1.10.0
cargo: >=1.2.3 <1.10.0-0, best 1.9.0
1.2.4-rc.0
'02' has a leading zero
Version: {major, minor, patch, prerelease: List[Ident], build: List[Str]}; the numbers are refined to >= 0.Ident: a pre-release identifier, NumId{n} or StrId{s}.VersionErr: why a version is invalid: EmptyVersion, WrongPartCount{found}, BadNumber{part}, LeadingZero{part}, NumberTooLarge{part}, EmptyIdentifier{section}, BadIdentifier{ident}.Part: Major | Minor | Patch | Prerelease | Build, the result of diff.Range, Comparator, Op: a range is a list of comparator sets (OR of ANDs); Op is OpEq | OpGt | OpGe | OpLt | OpLe.RangeErr: {text, reason}, the comparator that failed and why.version(major, minor, patch): a release version.parse(s): strict SemVer 2.0.0; no spaces, no v, no leading zeros in numbers or numeric pre-release identifiers, numbers must fit an Int.parse_loose(s): trims spaces and drops leading =, v and V, then parses strictly (" =v1.2.3 ").coerce(s): the first version-like run of digits, as node-semver's coerce ("v1.2" is 1.2.0, "1.2.3.4" is 1.2.3, "x 7 y" is 7.0.0); none if there is none.is_valid(s): whether parse succeeds.format_version(v): the canonical text.describe_error(e): a readable message for a VersionErr.compare(a, b): SemVer precedence (-1, 0, 1); build metadata is ignored.compare_total(a, b): precedence, then build metadata, so it is consistent with ==. pub impl Ord for Version uses it, so <, <=, >, >= work on versions.precedence_eq(a, b): same precedence.sort_versions(vs), sort_versions_desc(vs): stable sorts in SemVer order. The builtin sort stays structural and does not follow SemVer, so use these.highest(vs), lowest(vs): the extremes, none for an empty list.diff(a, b): the highest component that differs.is_prerelease(v), is_stable(v) (a release with major 1 or more), is_compatible(base, candidate) (Cargo's caret rule).bump_major(v), bump_minor(v), bump_patch(v): as npm's inc: a pre-release of the target version becomes that release (2.0.0-rc.1 to 2.0.0).bump_pre(v, id): the next pre-release (1.2.3 to 1.2.4-rc.0 to 1.2.4-rc.1; another id restarts at id.0; "" uses bare numbers).bump_premajor(v, id), bump_preminor(v, id), bump_prepatch(v, id): the next version as its first pre-release.release(v), strip_build(v), with_prerelease(v, text), with_build(v, text).parse_cargo(s): Cargo requirements: ^, ~, =, >, >=, <, <=, bare versions (caret), wildcards *, 1.x, 1.2.* (exact), comma-separated ANDs.parse_npm(s): node-semver ranges: ||, hyphen ranges, X-ranges, ~ and ~>, ^ with the 0.x rules, v and = prefixes, space-separated ANDs, "" and * for anything.parse_npm_with(s, include_prerelease): node's includePrerelease reading, where wildcard lower bounds become -0.matches(r, v): with the pre-release rule both ecosystems use: a pre-release matches only a set with a comparator on the same major.minor.patch that has a pre-release.matches_with(r, v, include_prerelease): the same, optionally without the pre-release rule.filter_matching(vs, r), max_satisfying(vs, r), min_satisfying(vs, r).min_version(r): the lowest version that can match, as node-semver's minVersion.intersects(a, b): whether some version (pre-releases included) matches both; exact, since it knows nothing lies between 1.0.0 and 1.0.1-0.any_range(), exact(v), caret(v), tilde(v), range_or(a, b), range_and(a, b): build ranges in code.format_range(r): the canonical comparator form, >=1.2.0 <2.0.0-0 || >=3.0.0-rc.1.Ranges are desugared to plain comparators the way node-semver does it: derived upper bounds are written <2.0.0-0, so no pre-release of the bound slips in, and Cargo requirements match what the Cargo semver crate matches on its test tables. Exact versions print with their = (=1.2.3), and sets join with || .
1.2.3beta, numbers with leading zeros): use coerce or parse_loose.subset, simplifyRange, gtr/ltr/outside from node-semver, and Ruby's or Python's (PEP 440) version schemes.include_prerelease, a range parsed with parse_npm (not parse_npm_with) keeps its lower bounds as written, so 1.x does not take 1.0.0-rc.tools/registry/registry.ssp of sspur-packages) is private to that tool, has no pre-release identifiers and no ranges, so this package does not build on it.sspur add semver
MIT licensed.
pub type Ident = NumId{n: Int} | StrId{s: Str} derive Eq, ShowOne pre-release identifier: a number (compared numerically) or an alphanumeric word (compared in ASCII order). Numbers sort before words.
pub type Version = {major: Int where _ >= 0, minor: Int where _ >= 0, patch: Int where _ >= 0, prerelease: List[Ident], build: List[Str]} derive Eq, ShowA version major.minor.patch[-prerelease][+build]. Build metadata never affects precedence.
pub type VersionErr = EmptyVersion | WrongPartCount{found: Int} | BadNumber{part: Str} | LeadingZero{part: Str} | NumberTooLarge{part: Str} | EmptyIdentifier{section: Str} | BadIdentifier{ident: Str} derive Eq, ShowWhy a text is not a valid version. part is the offending piece of the input.
pub type Part = Major | Minor | Patch | Prerelease | Build derive Eq, ShowThe highest component in which two versions differ.
pub type Op = OpEq | OpGt | OpGe | OpLt | OpLe derive Eq, ShowA comparison operator of a range comparator.
pub type Comparator = {op: Op, ver: Version} derive Eq, ShowOne comparator, such as >=1.2.0.
pub type Range = {sets: List[List[Comparator]]} derive Eq, ShowA range: a version matches when it matches every comparator of at least one set. An empty set matches every version (pre-releases only with include_prerelease).
pub type RangeErr = {text: Str, reason: Str} derive Eq, ShowWhy a text is not a valid range: text is the offending comparator or the whole input.
fn version(major: Int where _ >= 0, minor: Int where _ >= 0, patch: Int where _ >= 0) -> VersionThe release version major.minor.patch, with no pre-release or build.
fn parse(s: Str) -> Res[Version, VersionErr]Parses a version exactly as SemVer 2.0.0 writes it: three numbers without leading zeros, then optional - pre-release and + build identifiers. No surrounding spaces or v.
fn parse_loose(s: Str) -> Res[Version, VersionErr]Parses like parse after trimming spaces and dropping any leading =, v or V characters (" =v1.2.3 " is 1.2.3). Everything after them must still be a strict version.
fn is_valid(s: Str) -> BoolTrue when s is a strict SemVer 2.0.0 version.
fn coerce(s: Str) -> Opt[Version]The first version-like run of digits in s, as node-semver's coerce: up to three dot-separated numbers, missing ones are 0, and anything around them (a v, a fourth number, a pre-release) is ignored: "v1.2" is 1.2.0, "1.2.3.4" is 1.2.3, "x 7 y" is 7.0.0. A number too large for Int makes the result none.
fn describe_error(e: VersionErr) -> StrA readable message for a version error.
fn format_version(v: Version) -> StrThe canonical text of a version, 1.2.3-rc.1+build.5.
fn compare(a: Version, b: Version) -> IntSemVer precedence (spec item 11): -1, 0 or 1. Numbers compare numerically, a pre-release is lower than its release, identifiers compare left to right, and build metadata is ignored.
fn compare_total(a: Version, b: Version) -> IntA total order consistent with ==: precedence first, then build metadata (none first, then identifier by identifier, numeric ones numerically and before alphanumeric ones, as the Cargo semver crate orders them).
impl Ord for Version<, <=, > and >= on versions follow compare_total.
fn precedence_eq(a: Version, b: Version) -> BoolTrue when the two versions have the same precedence (they may differ in build metadata).
fn sort_versions(vs: List[Version]) -> List[Version]The versions in ascending compare_total order (stable). The builtin sort is structural and does not follow SemVer order, so use this instead.
fn sort_versions_desc(vs: List[Version]) -> List[Version]The versions in descending compare_total order.
fn highest(vs: List[Version]) -> Opt[Version]The highest version by compare_total, none for an empty list.
fn lowest(vs: List[Version]) -> Opt[Version]The lowest version by compare_total, none for an empty list.
fn diff(a: Version, b: Version) -> Opt[Part]The highest component in which a and b differ, none when they are equal.
fn is_prerelease(v: Version) -> BoolTrue when v has pre-release identifiers.
fn is_stable(v: Version) -> BoolTrue when v is a release with major version 1 or more: SemVer's stable public API.
fn is_compatible(base: Version, candidate: Version) -> BoolTrue when candidate can replace base under Cargo's default (caret) rule: the same leftmost non-zero component and not lower; pre-releases only of the same major.minor.patch.
fn release(v: Version) -> VersionThe release without pre-release or build identifiers.
fn strip_build(v: Version) -> Versionv without build metadata.
fn with_prerelease(v: Version, text: Str) -> Res[Version, VersionErr]v with the pre-release identifiers parsed from text ("rc.1"); "" removes them.
fn with_build(v: Version, text: Str) -> Res[Version, VersionErr]v with the build identifiers parsed from text ("sha.5114f85"); "" removes them.
fn bump_major(v: Version) -> VersionThe next major version, as npm's inc: 1.2.3 becomes 2.0.0, and a pre-release of a major version (2.0.0-rc.1) becomes that release. Build metadata is dropped.
fn bump_minor(v: Version) -> VersionThe next minor version: 1.2.3 becomes 1.3.0, and 1.3.0-rc.1 becomes 1.3.0.
fn bump_patch(v: Version) -> VersionThe next patch version: 1.2.3 becomes 1.2.4, and 1.2.4-rc.1 becomes 1.2.4.
fn bump_pre(v: Version, id: Str) -> Res[Version, VersionErr]The next pre-release, as npm's inc("prerelease", id): a release gets the next patch and -id.0 (1.2.3 to 1.2.4-rc.0); a pre-release whose first identifier is id (or any when id is "") gets its last number incremented (1.2.4-rc.0 to 1.2.4-rc.1) or .0 appended; another identifier restarts at -id.0. id must be "" or an alphanumeric identifier that is not a number; otherwise the result is an error.
fn bump_premajor(v: Version, id: Str) -> Res[Version, VersionErr]The next major version as a first pre-release: 1.2.3 with "rc" becomes 2.0.0-rc.0.
fn bump_preminor(v: Version, id: Str) -> Res[Version, VersionErr]The next minor version as a first pre-release: 1.2.3 with "rc" becomes 1.3.0-rc.0.
fn bump_prepatch(v: Version, id: Str) -> Res[Version, VersionErr]The next patch version as a first pre-release: 1.2.3 with "rc" becomes 1.2.4-rc.0.
fn any_range() -> RangeThe range that matches every release (*).
fn exact(v: Version) -> RangeThe range =v: only versions with the same precedence as v.
fn caret(v: Version) -> RangeThe caret range ^v: from v up to the next change of its leftmost non-zero component (^1.2.3 is >=1.2.3 <2.0.0-0, ^0.2.3 is >=0.2.3 <0.3.0-0, ^0.0.3 is >=0.0.3 <0.0.4-0).
fn tilde(v: Version) -> RangeThe tilde range ~v: from v up to the next minor version (~1.2.3 is >=1.2.3 <1.3.0-0).
fn range_or(a: Range, b: Range) -> RangeA version matches range_or(a, b) when it matches a or b.
fn range_and(a: Range, b: Range) -> RangeA version matches range_and(a, b) when it matches both a and b.
fn parse_cargo(s: Str) -> Res[Range, RangeErr]Parses a Cargo version requirement: comparators separated by commas, each ^, ~, =, >, >=, <, <= or none (a bare version is a caret requirement, a bare wildcard such as 1.2.* an exact one), with partial versions and *, x or X wildcards. Spaces around operators are allowed. Cargo has no ||, hyphen ranges or v prefixes, so those are errors, as is an empty text.
fn parse_npm(s: Str) -> Res[Range, RangeErr]Parses an npm (node-semver) range: sets separated by ||, each a space-separated list of comparators (>=1.2.7 <1.3.0) or one hyphen range (1.2 - 2.3.4). Comparators take =, <, <=, >, >=, ~ (or ~>), ^ or no operator, partial versions, x, X and * wildcards, and a v or = before the version. A bare version is exact, a partial one an X-range (1.2 is >=1.2.0 <1.3.0-0), and an empty set means any version. Derived upper bounds are written <2.0.0-0, as node-semver does, so pre-releases of the bound never match.
fn parse_npm_with(s: Str, include_prerelease: Bool) -> Res[Range, RangeErr]Like parse_npm with node-semver's includePrerelease reading: lower bounds that come from partial versions and wildcards are written with -0 (1.x is >=1.0.0-0 <2.0.0-0), so with matches_with(r, v, true) the pre-releases inside the range match too.
fn matches(r: Range, v: Version) -> BoolTrue when v matches r. A pre-release matches only a set with a comparator of the same major.minor.patch that has a pre-release itself (so >=1.2.3-beta takes 1.2.3-rc but not 1.2.4-rc), the rule both Cargo and npm follow.
fn matches_with(r: Range, v: Version, include_prerelease: Bool) -> BoolLike matches, but include_prerelease drops the pre-release rule, as node-semver's option of that name: a pre-release then matches whenever every comparator holds.
fn filter_matching(vs: List[Version], r: Range) -> List[Version]The versions of vs that match r, in their order.
fn max_satisfying(vs: List[Version], r: Range) -> Opt[Version]The highest version of vs that matches r.
fn min_satisfying(vs: List[Version], r: Range) -> Opt[Version]The lowest version of vs that matches r.
fn intersects(a: Range, b: Range) -> BoolTrue when some version, counting pre-releases (the include_prerelease reading), matches both a and b. Exact: it knows that nothing lies between 1.0.0 and 1.0.1-0.
fn min_version(r: Range) -> Opt[Version]The lowest version that can match r under include_prerelease, as node-semver's minVersion: 0.0.0 or 0.0.0-0 when they match, else the lowest of the sets' tightest lower bounds (>1.2.3 gives 1.2.4, >1.2.3-rc gives 1.2.3-rc.0) when it matches; none when no such version matches.
fn format_range(r: Range) -> StrThe canonical text of a range, in node-semver's form: comparators joined by spaces, sets by || , an empty set as * (parse_npm("^1.2") gives >=1.2.0 <2.0.0-0).
| Version | Published | Commit | Source | Effects |
|---|---|---|---|---|
| 0.1.0 | 2026-10-16 | 50e4772cb1 | snapshot |