diff --git a/semver/coerce.ts b/semver/coerce.ts new file mode 100644 index 000000000000..379600159b20 --- /dev/null +++ b/semver/coerce.ts @@ -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_IDENTIFIER}(?:\\.${PRERELEASE_IDENTIFIER})*))`; +const BUILD_IDENTIFIER = "[0-9A-Za-z-]+"; +const BUILD = + `(?:\\+(?${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 + } +} diff --git a/semver/coerce_test.ts b/semver/coerce_test.ts new file mode 100644 index 000000000000..2c913f52ed97 --- /dev/null +++ b/semver/coerce_test.ts @@ -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)); + }, + ); + } +}); diff --git a/semver/deno.json b/semver/deno.json index 3b07daba7240..0e82397f001a 100644 --- a/semver/deno.json +++ b/semver/deno.json @@ -4,6 +4,7 @@ "exports": { ".": "./mod.ts", "./can-parse": "./can_parse.ts", + "./coerce": "./coerce.ts", "./compare": "./compare.ts", "./difference": "./difference.ts", "./equals": "./equals.ts", @@ -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", diff --git a/semver/min_version.ts b/semver/min_version.ts new file mode 100644 index 000000000000..d4158c5ee47f --- /dev/null +++ b/semver/min_version.ts @@ -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 "<": + 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; + } +} diff --git a/semver/min_version_test.ts b/semver/min_version_test.ts new file mode 100644 index 000000000000..bfd1c7e33e81 --- /dev/null +++ b/semver/min_version_test.ts @@ -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); + }); + } +}); diff --git a/semver/mod.ts b/semver/mod.ts index f6ecf905d002..016501604344 100644 --- a/semver/mod.ts +++ b/semver/mod.ts @@ -274,6 +274,7 @@ * * @module */ +export * from "./coerce.ts"; export * from "./compare.ts"; export * from "./difference.ts"; export * from "./format.ts"; @@ -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";