Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions semver/coerce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright 2018-2026 the Deno authors. MIT license.
// This module is browser compatible.
import type { SemVer } from "./types.ts";
import { parse } from "./parse.ts";

const MAX_SAFE_COMPONENT_LENGTH = 16;

const PRERELEASE_IDENTIFIER = "(?:0|[1-9]\\d*|\\d*[a-zA-Z-][a-zA-Z0-9-]*)";
const PRERELEASE =
`(?:-(?<prerelease>${PRERELEASE_IDENTIFIER}(?:\\.${PRERELEASE_IDENTIFIER})*))`;
const BUILD_IDENTIFIER = "[0-9A-Za-z-]+";
const BUILD =
`(?:\\+(?<buildmetadata>${BUILD_IDENTIFIER}(?:\\.${BUILD_IDENTIFIER})*))`;

const COERCE_PLAIN = `(^|[^\\d])(\\d{1,${MAX_SAFE_COMPONENT_LENGTH}})` +
`(?:\\.(\\d{1,${MAX_SAFE_COMPONENT_LENGTH}}))?` +
`(?:\\.(\\d{1,${MAX_SAFE_COMPONENT_LENGTH}}))?`;

const COERCE_REGEXP = new RegExp(`${COERCE_PLAIN}(?:$|[^\\d])`);
const COERCE_FULL_REGEXP = new RegExp(
`${COERCE_PLAIN}${PRERELEASE}?${BUILD}?(?:$|[^\\d])`,
);
const COERCE_RTL_REGEXP = new RegExp(COERCE_REGEXP.source, "g");
const COERCE_RTL_FULL_REGEXP = new RegExp(COERCE_FULL_REGEXP.source, "g");

/** Options for {@linkcode coerce}. */
export interface CoerceOptions {
/**
* When `true`, the coercion will also include prerelease and build metadata.
*
* @default {false}
*/
includePrerelease?: boolean;
/**
* When `true`, the coercion will search from right to left.
* For example, `coerce("1.2.3.4", { rtl: true })` returns `parse("2.3.4")`
* instead of `parse("1.2.3")`.
*
* @default {false}
*/
rtl?: boolean;
}

/**
* Coerces a version string or number into a valid SemVer, or returns
* `undefined` if no coercible value is found.
*
* This is useful for extracting semver-like versions from strings that are
* not strictly valid semver (e.g., `"v1"` becomes `1.0.0`, `"3.4.5.6"`
* becomes `3.4.5`).
*
* @example Usage
* ```ts
* import { coerce } from "@std/semver/coerce";
* import { assertEquals } from "@std/assert";
*
* assertEquals(coerce("v1"), { major: 1, minor: 0, patch: 0, prerelease: [], build: [] });
* assertEquals(coerce("42.6.7.9.3-alpha"), { major: 42, minor: 6, patch: 7, prerelease: [], build: [] });
* assertEquals(coerce("invalid"), undefined);
* ```
*
* @param version The value to coerce into a SemVer.
* @param options Options for coercion.
* @returns A valid SemVer, or `undefined` if the value cannot be coerced.
*/
export function coerce(
version: string | number,
options?: CoerceOptions,
): SemVer | undefined {
if (typeof version === "number") {
version = String(version);
}

if (typeof version !== "string") {
return;
}

const includePrerelease = options?.includePrerelease ?? false;

let match: RegExpExecArray | null = null;

if (!options?.rtl) {
match = version.match(
includePrerelease ? COERCE_FULL_REGEXP : COERCE_REGEXP,
) as RegExpExecArray | null;
} else {
const coerceRtlRegex = includePrerelease
? COERCE_RTL_FULL_REGEXP
: COERCE_RTL_REGEXP;
let next: RegExpExecArray | null;
while (
(next = coerceRtlRegex.exec(version)) &&
(!match || match.index + match[0].length !== version.length)
) {
if (
!match ||
next.index + next[0].length !== match.index + match[0].length
) {
match = next;
}
coerceRtlRegex.lastIndex = next.index + next[1]!.length + next[2]!.length;
}
coerceRtlRegex.lastIndex = 0;
}

if (match === null) {
return;
}

const major = match[2]!;
const minor = match[3] ?? "0";
const patch = match[4] ?? "0";
const prerelease = includePrerelease && match[5] ? `-${match[5]}` : "";
const build = includePrerelease && match[6] ? `+${match[6]}` : "";

try {
return parse(`${major}.${minor}.${patch}${prerelease}${build}`);
} catch {
// invalid semver, return undefined
}
}
78 changes: 78 additions & 0 deletions semver/coerce_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright Isaac Z. Schlueter and Contributors. All rights reserved. ISC license.
// Copyright 2018-2026 the Deno authors. MIT license.
import { assertEquals } from "@std/assert";
import { parse } from "./parse.ts";
import { coerce } from "./coerce.ts";

Deno.test("coerce() basic coercion", async (t) => {
const tests: [string | number, string][] = [
["1", "1.0.0"],
["1.2", "1.2.0"],
["1.2.3", "1.2.3"],
["v1", "1.0.0"],
["v1.2", "1.2.0"],
["v1.2.3", "1.2.3"],
[42, "42.0.0"],
["42.6.7.9.3-alpha", "42.6.7"],
["1.2.3.4", "1.2.3"],
[" 1.2.3 ", "1.2.3"],
["hello 1.2.3 world", "1.2.3"],
["v1.0.0-alpha", "1.0.0"],
];

for (const [input, expected] of tests) {
await t.step(`coerce(${JSON.stringify(input)}) = ${expected}`, () => {
assertEquals(coerce(input), parse(expected));
});
}
});

Deno.test("coerce() returns undefined for non-coercible values", async (t) => {
const tests: string[] = [
"",
"invalid",
"hello world",
];

for (const input of tests) {
await t.step(`coerce(${JSON.stringify(input)}) = undefined`, () => {
assertEquals(coerce(input), undefined);
});
}
});

Deno.test("coerce() with includePrerelease", async (t) => {
const tests: [string, string][] = [
["1.2.3-alpha.1", "1.2.3-alpha.1"],
["1.2.3-alpha+build", "1.2.3-alpha+build"],
["1.2.3+build.123", "1.2.3+build.123"],
];

for (const [input, expected] of tests) {
await t.step(
`coerce("${input}", { includePrerelease: true }) = ${expected}`,
() => {
assertEquals(
coerce(input, { includePrerelease: true }),
parse(expected),
);
},
);
}
});

Deno.test("coerce() with rtl", async (t) => {
const tests: [string, string][] = [
["1.2.3.4", "2.3.4"],
["1.2.3.4.5", "3.4.5"],
];

for (const [input, expected] of tests) {
await t.step(
`coerce("${input}", { rtl: true }) = ${expected}`,
() => {
assertEquals(coerce(input, { rtl: true }), parse(expected));
},
);
}
});
2 changes: 2 additions & 0 deletions semver/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"exports": {
".": "./mod.ts",
"./can-parse": "./can_parse.ts",
"./coerce": "./coerce.ts",
"./compare": "./compare.ts",
"./difference": "./difference.ts",
"./equals": "./equals.ts",
Expand All @@ -20,6 +21,7 @@
"./less-than-range": "./less_than_range.ts",
"./max-satisfying": "./max_satisfying.ts",
"./min-satisfying": "./min_satisfying.ts",
"./min-version": "./min_version.ts",
"./not-equals": "./not_equals.ts",
"./parse": "./parse.ts",
"./parse-range": "./parse_range.ts",
Expand Down
92 changes: 92 additions & 0 deletions semver/min_version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2018-2026 the Deno authors. MIT license.
// This module is browser compatible.
import type { Range, SemVer } from "./types.ts";
import { greaterThan } from "./greater_than.ts";
import { satisfies } from "./satisfies.ts";

/**
* Returns the lowest version that satisfies the range, or `undefined` if no
* version satisfies the range.
*
* @example Usage
* ```ts
* import { minVersion, parseRange, parse } from "@std/semver";
* import { assertEquals } from "@std/assert";
*
* const range = parseRange(">=1.0.0 <2.0.0");
* assertEquals(minVersion(range), parse("1.0.0"));
*
* const range2 = parseRange(">1.0.0");
* assertEquals(minVersion(range2), parse("1.0.1"));
*
* const range3 = parseRange(">1.0.0-0");
* assertEquals(minVersion(range3), parse("1.0.0-0.0"));
* ```
*
* @param range The range to find the minimum version for.
* @returns The minimum version that satisfies the range, or `undefined`.
*/
export function minVersion(range: Range): SemVer | undefined {
const ZERO: SemVer = {
major: 0,
minor: 0,
patch: 0,
};
if (satisfies(ZERO, range)) return ZERO;

const ZERO_PRE: SemVer = {
major: 0,
minor: 0,
patch: 0,
prerelease: [0],
build: [],
};
if (satisfies(ZERO_PRE, range)) return ZERO_PRE;

let minver: SemVer | undefined;

for (const comparators of range) {
let setMin: SemVer | undefined;

for (const comparator of comparators) {
const compver: SemVer = {
major: comparator.major,
minor: comparator.minor,
patch: comparator.patch,
prerelease: [...(comparator.prerelease ?? [])],
build: [...(comparator.build ?? [])],
};

switch (comparator.operator) {
case ">":
if (!compver.prerelease || compver.prerelease.length === 0) {
compver.patch++;
} else {
compver.prerelease = [...compver.prerelease, 0];
}
// falls through
case "=":
case undefined:
case ">=":
if (!setMin || greaterThan(compver, setMin)) {
setMin = compver;
}
break;
case "<":
Comment thread
sxzz marked this conversation as resolved.
case "<=":
case "!=":
break;
default:
throw new Error(`Unexpected operator: ${comparator.operator}`);
}
}

if (setMin && (!minver || greaterThan(minver, setMin))) {
minver = setMin;
}
}

if (minver && satisfies(minver, range)) {
return minver;
}
}
59 changes: 59 additions & 0 deletions semver/min_version_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright Isaac Z. Schlueter and Contributors. All rights reserved. ISC license.
// Copyright 2018-2026 the Deno authors. MIT license.
import { assertEquals } from "@std/assert";
import type { SemVer } from "./types.ts";
import { parse } from "./parse.ts";
import { parseRange } from "./parse_range.ts";
import { minVersion } from "./min_version.ts";

Deno.test("minVersion()", async (t) => {
const tests: [string, SemVer][] = [
[">=1.0.0 <2.0.0", parse("1.0.0")],
[">1.0.0", parse("1.0.1")],
[">=1.2.3", parse("1.2.3")],
["^1.2.3", parse("1.2.3")],
["^0.2.3", parse("0.2.3")],
["~1.2.3", parse("1.2.3")],
["~0.2.3", parse("0.2.3")],
[">1.0.0-0", parse("1.0.0-0.0")],
[">=1.0.0-0", parse("1.0.0-0")],
[">1.0.0-beta", parse("1.0.0-beta.0")],
[">=0.0.0", { major: 0, minor: 0, patch: 0 }],
["*", { major: 0, minor: 0, patch: 0 }],
["1.x", parse("1.0.0")],
["1.2.x", parse("1.2.0")],
["1 || 2 || 3", parse("1.0.0")],
[">=1.0.0 || >=2.0.0", parse("1.0.0")],
[">=2.0.0 || >=1.0.0", parse("1.0.0")],
[">2.0.0 || >=1.0.0", parse("1.0.0")],
[">=1.0.0", parse("1.0.0")],
[">1.0.0", parse("1.0.1")],
[">=2.0.0", parse("2.0.0")],
[">2.0.0", parse("2.0.1")],
[">=1.0.0 <1.1.0", parse("1.0.0")],
[">0.0.0", parse("0.0.1")],
[">=0.0.0-0", { major: 0, minor: 0, patch: 0 }],
];

for (const [r, expected] of tests) {
await t.step(`minVersion("${r}")`, () => {
const range = parseRange(r);
const actual = minVersion(range);
assertEquals(actual, expected);
});
}
});

Deno.test("minVersion() returns undefined for unsatisfiable range", async (t) => {
const tests: string[] = [
"<0.0.0",
];

for (const r of tests) {
await t.step(`minVersion("${r}") = undefined`, () => {
const range = parseRange(r);
const actual = minVersion(range);
assertEquals(actual, undefined);
});
}
});
2 changes: 2 additions & 0 deletions semver/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@
*
* @module
*/
export * from "./coerce.ts";
export * from "./compare.ts";
export * from "./difference.ts";
export * from "./format.ts";
Expand All @@ -282,6 +283,7 @@ export * from "./increment.ts";
export * from "./is_semver.ts";
export * from "./max_satisfying.ts";
export * from "./min_satisfying.ts";
export * from "./min_version.ts";
export * from "./parse_range.ts";
export * from "./parse.ts";
export * from "./range_intersects.ts";
Expand Down
Loading