-
Notifications
You must be signed in to change notification settings - Fork 671
feat(semver): add minVersion and coerce functions
#7038
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sxzz
wants to merge
5
commits into
denoland:main
Choose a base branch
from
sxzz:feat/semver-min-version-coerce
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)); | ||
| }, | ||
| ); | ||
| } | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 "<": | ||
| 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; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| } | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.