mirror of
https://github.com/0x5eal/semver-luau.git
synced 2024-12-14 00:50:36 +00:00
Erica Marigold
f6220d6295
Previously, if the ordinal contained dots (like 1.2.3-beta.3.4.5), although it was invalid, an error would not be returned, which was problematic. This has now been fixed. Test cases are also included to prevent future regressions.
426 lines
13 KiB
Text
426 lines
13 KiB
Text
local Option = require("../luau_packages/option")
|
|
type Option<T> = Option.Option<T>
|
|
|
|
local Result = require("../luau_packages/result")
|
|
type Result<T, E> = Result.Result<T, E>
|
|
|
|
local function stringStartsWith(str: string, sub: string)
|
|
return string.sub(str, 1, #sub) == sub
|
|
end
|
|
|
|
local function hasLeadingZeros(num: string)
|
|
return string.match(num, "^(0+)") ~= nil
|
|
end
|
|
|
|
local function doLeadingZeroesCheck(type: "major" | "minor" | "patch", component: string)
|
|
-- We only do this check if each component has more than 1 digit as an optimization
|
|
if #component > 1 and hasLeadingZeros(component) then
|
|
return Result.Err({
|
|
msg = Option.Some(`Found disallowed leading zeros in {string.upper(type)} component`),
|
|
kind = {
|
|
id = "LeadingZerosPresent",
|
|
component = type,
|
|
} :: ParseError,
|
|
} :: SemverError)
|
|
end
|
|
|
|
return Result.Ok(nil)
|
|
end
|
|
|
|
type InvalidTypes = "char" | "symbol"
|
|
local function getInvalidTypeName(invalidComponent: string): InvalidTypes
|
|
local typePatterns: { [InvalidTypes]: string } = {
|
|
char = "%a",
|
|
symbol = "%p",
|
|
}
|
|
|
|
local presentType: InvalidTypes
|
|
|
|
for type: InvalidTypes, pattern in typePatterns do
|
|
if string.match(invalidComponent, pattern) then
|
|
presentType = type
|
|
break
|
|
end
|
|
end
|
|
|
|
return presentType
|
|
end
|
|
|
|
--[=[
|
|
@class Semver
|
|
|
|
A Luau library to parse and compare [Semantic Versioning](https://semver.org) compatible version numbers.
|
|
|
|
Semantic Versioning (Semver) is a versioning scheme for software that uses a three-part version number format, `MAJOR.MINOR.PATCH`.
|
|
Additionally, Semver defines rules on when to increment each of these parts:
|
|
|
|
- `MAJOR` version increments indicate incompatible API changes.
|
|
- `MINOR` version increments indicate the addition of functionality in a backward-compatible manner.
|
|
- `PATCH` version increments indicate backward-compatible bug fixes.
|
|
|
|
Semver also allows for optional pre-release and build metadata tags.
|
|
|
|
For a detailed set of guidelines that Semver version numbers follow, visit the previously linked website containing the full
|
|
Semver specification.
|
|
]=]
|
|
local Semver = {}
|
|
|
|
export type SemverImpl = typeof(setmetatable({} :: Version, Semver))
|
|
|
|
--[=[
|
|
@type PreleaseType "alpha" | "beta" | "rc"
|
|
@within Semver
|
|
|
|
A type representing the different prerelease stages
|
|
]=]
|
|
export type PreleaseType = "alpha" | "beta" | "rc"
|
|
|
|
--[=[
|
|
@interface PrereleaseVersion
|
|
@within Semver
|
|
|
|
@field type PreleaseType -- The type of prerelease stage
|
|
@field ordinal Option<number> -- Optional ordinal number for the prerelease
|
|
]=]
|
|
export type PrereleaseVersion = {
|
|
type: PreleaseType,
|
|
ordinal: Option<number>,
|
|
}
|
|
|
|
--[=[
|
|
@interface Version
|
|
@within Semver
|
|
|
|
@field major number -- Major version number
|
|
@field minor number -- Minor version number
|
|
@field patch number -- Patch version number
|
|
@field buildMetadata Option<string> -- Optional build metadata
|
|
@field prerelease Option<PrereleaseVersion> -- Optional prerelease information
|
|
]=]
|
|
export type Version = {
|
|
major: number,
|
|
minor: number,
|
|
patch: number,
|
|
buildMetadata: Option<string>,
|
|
prerelease: Option<PrereleaseVersion>,
|
|
}
|
|
|
|
--[=[
|
|
@type SemverResult<T, E = SemverError> = Result<T, E>
|
|
@within Semver
|
|
]=]
|
|
export type SemverResult<T, E = SemverError> = Result<T, E>
|
|
|
|
--[=[
|
|
@interface SemverError
|
|
@within Semver
|
|
|
|
@field msg Option<string> -- Optional error message
|
|
@field kind SemverErrorKind -- The kind of error that occurred
|
|
]=]
|
|
export type SemverError = {
|
|
msg: Option<string>,
|
|
kind: SemverErrorKind,
|
|
}
|
|
|
|
--[=[
|
|
@type SemverErrorKind ParseError | {}
|
|
@within Semver
|
|
|
|
All the possible error types that can be returned
|
|
]=]
|
|
export type SemverErrorKind = ParseError | {}
|
|
|
|
--[=[
|
|
@type ParseError { id: string, component: string?, expected: string?, got: string?, components: string?, type: string? }
|
|
@within Semver
|
|
|
|
All the possible error types that can occur during semver parsing:
|
|
|
|
* `MandatoryComponentMissing`: When required version components are missing
|
|
* `InvalidComponentType`: When a version component has an invalid type
|
|
* `LeadingZerosPresent`: When a version component has leading zeros
|
|
* `InvalidIdentifierCharacter`: When prerelease or metadata contains invalid characters
|
|
* `InvalidPrereleaseType`: When prerelease type is not alpha, beta, or rc
|
|
* `InvalidPrereleaseOrdinalType`: When prerelease ordinal is not a number
|
|
]=]
|
|
export type ParseError =
|
|
{ id: "MandatoryComponentMissing", components: { string } }
|
|
| {
|
|
id: "InvalidComponentType",
|
|
component: "major" | "minor" | "patch",
|
|
expected: "number",
|
|
got: "char" | "symbol" | "unknown",
|
|
}
|
|
| { id: "LeadingZerosPresent", component: "major" | "minor" | "patch" }
|
|
| { id: "InvalidIdentifierCharacter", component: "prerelease" | "metadata" }
|
|
| { id: "InvalidPrereleaseType", component: "prerelease", type: string }
|
|
| { id: "InvalidPrereleaseOrdinalType", expected: "number", got: "char" | "symbol" }
|
|
|
|
--[=[
|
|
@prop PRERELEASE_LEX_ORDER table
|
|
@within Semver
|
|
@private
|
|
|
|
Lexicographical ordering for prerelease types
|
|
]=]
|
|
local PRERELEASE_LEX_ORDER: { [PreleaseType]: number } = table.freeze({
|
|
alpha = 1,
|
|
beta = 2,
|
|
rc = 3,
|
|
})
|
|
|
|
--[=[
|
|
@within Semver
|
|
@function parse
|
|
|
|
Parses a version string into a Semver instance
|
|
|
|
@param ver string -- The version string to parse
|
|
@return SemverResult<SemverImpl> -- Result containing either the parsed Semver or an error
|
|
]=]
|
|
function Semver.parse(ver: string): SemverResult<SemverImpl>
|
|
local components = string.split(if stringStartsWith(ver, "v") then string.sub(ver, 2) else ver, ".")
|
|
if #components < 3 then
|
|
return Result.Err({
|
|
msg = Option.Some(`Expected MAJOR.MINOR.PATCH format, missing {#components} / 3 components`),
|
|
kind = {
|
|
id = "MandatoryComponentMissing",
|
|
components = components,
|
|
} :: ParseError,
|
|
} :: SemverError)
|
|
end
|
|
|
|
local patchStr, ext = string.match(components[3], "(%d+)([-+]?.*)")
|
|
if patchStr == nil then
|
|
return Result.Err({
|
|
msg = Option.Some(`Expected patch to be only a number`),
|
|
kind = {
|
|
id = "InvalidComponentType",
|
|
expected = "number",
|
|
got = getInvalidTypeName(components[3]),
|
|
component = "patch",
|
|
} :: ParseError,
|
|
} :: SemverError)
|
|
end
|
|
|
|
local major, minor, patch = tonumber(components[1]), tonumber(components[2]), tonumber(patchStr)
|
|
if major == nil or minor == nil or patch == nil then
|
|
local invalidComponentType: "major" | "minor" | "patch", invalidComponent: string
|
|
|
|
if major == nil then
|
|
invalidComponentType, invalidComponent = "major", components[1]
|
|
elseif minor == nil then
|
|
invalidComponentType, invalidComponent = "minor", components[2]
|
|
elseif patch == nil then
|
|
invalidComponentType, invalidComponent = "patch", patchStr :: string
|
|
end
|
|
|
|
local presentType: InvalidTypes = getInvalidTypeName(invalidComponent)
|
|
return Result.Err({
|
|
msg = Option.Some(
|
|
`Expected {string.upper(invalidComponentType)} to be only a number, but got {presentType}`
|
|
),
|
|
kind = {
|
|
id = "InvalidComponentType",
|
|
expected = "number",
|
|
got = presentType,
|
|
component = invalidComponentType,
|
|
} :: ParseError,
|
|
} :: SemverError)
|
|
end
|
|
|
|
-- All components were valid numbers, but we check to see if they had any leading zeros
|
|
-- Leading zeros are invalid in semver
|
|
local majorLeadingZeros = doLeadingZeroesCheck("major", components[1])
|
|
if majorLeadingZeros:isErr() then
|
|
return majorLeadingZeros
|
|
end
|
|
|
|
local minorLeadingZeros = doLeadingZeroesCheck("minor", components[2])
|
|
if minorLeadingZeros:isErr() then
|
|
return minorLeadingZeros
|
|
end
|
|
|
|
local patchLeadingZeros = doLeadingZeroesCheck("patch", patchStr)
|
|
if patchLeadingZeros:isErr() then
|
|
return patchLeadingZeros
|
|
end
|
|
|
|
local parsed: Version = {
|
|
major = major :: number,
|
|
minor = minor :: number,
|
|
patch = patch :: number,
|
|
buildMetadata = Option.None :: Option<string>,
|
|
prerelease = Option.None :: Option<PrereleaseVersion>,
|
|
}
|
|
|
|
if ext ~= nil then
|
|
if stringStartsWith(ext, "-") then
|
|
-- Prerelease information
|
|
local prereleaseType = string.sub(ext, 2)
|
|
if prereleaseType ~= "alpha" and prereleaseType ~= "beta" and prereleaseType ~= "rc" then
|
|
return Result.Err({
|
|
msg = Option.Some(`Expected prerelease type to be alpha | beta | rc, but got {prereleaseType}`),
|
|
kind = {
|
|
id = "InvalidPrereleaseType",
|
|
component = "prerelease",
|
|
type = prereleaseType,
|
|
} :: ParseError,
|
|
} :: SemverError)
|
|
end
|
|
|
|
local function badPrereleaseType(prerelease: string): SemverResult<SemverImpl>
|
|
local invalidType: InvalidTypes = getInvalidTypeName(prerelease)
|
|
return Result.Err({
|
|
msg = Option.Some(`Expected PRERELEASE to only be a number, but got {invalidType}`),
|
|
kind = {
|
|
id = "InvalidPrereleaseOrdinalType",
|
|
expected = "number",
|
|
got = invalidType,
|
|
} :: ParseError,
|
|
} :: SemverError)
|
|
end
|
|
|
|
local prereleaseOrdinal = Option.None :: Option<number>
|
|
|
|
-- TODO: Cleanup this recovery logic of recomputing a bit more, maybe remove it entirely
|
|
if components[4] ~= nil then
|
|
local ordinalNum = tonumber(components[4])
|
|
if ordinalNum == nil then
|
|
-- If it wasn't a valid number for the ordinal, that must mean one of two things:
|
|
-- a) The PRERELEASE component was bad
|
|
-- b) The component has build metadata after prerelease info
|
|
-- Here, we handle both those cases
|
|
|
|
local potentialOrdinalNumber, potentialBuildMetadata = string.match(components[4], "(.*)+(.*)")
|
|
if potentialOrdinalNumber == nil then
|
|
return badPrereleaseType(components[4])
|
|
end
|
|
|
|
if potentialBuildMetadata ~= nil then
|
|
local fullBuildMetadata = potentialBuildMetadata
|
|
for i = 5, #components do
|
|
fullBuildMetadata ..= "." .. components[i]
|
|
end
|
|
|
|
parsed.buildMetadata = Option.Some(fullBuildMetadata :: string) :: Option<string>
|
|
end
|
|
|
|
if potentialOrdinalNumber ~= nil then
|
|
ordinalNum = tonumber(potentialOrdinalNumber)
|
|
if ordinalNum == nil then
|
|
return badPrereleaseType(components[4])
|
|
end
|
|
end
|
|
elseif #components > 4 then
|
|
-- The ordinal component was bad, we should error
|
|
local badPrerelease = ""
|
|
for i = 4, #components do
|
|
badPrerelease ..= "." .. components[i]
|
|
end
|
|
|
|
return badPrereleaseType(badPrerelease)
|
|
end
|
|
|
|
prereleaseOrdinal = Option.Some(ordinalNum :: number) :: Option<number>
|
|
end
|
|
|
|
parsed.prerelease = Option.Some({
|
|
type = prereleaseType,
|
|
ordinal = prereleaseOrdinal,
|
|
} :: PrereleaseVersion) :: Option<PrereleaseVersion>
|
|
end
|
|
|
|
if stringStartsWith(ext, "+") then
|
|
-- Build metadata information; we remove the leading `+` symbol and add
|
|
-- the rest of the components to the build metadata
|
|
local fullBuildMetadata = string.sub(ext, 2)
|
|
for i = 4, #components do
|
|
fullBuildMetadata ..= "." .. components[i]
|
|
end
|
|
|
|
parsed.buildMetadata = Option.Some(fullBuildMetadata) :: Option<string>
|
|
end
|
|
end
|
|
|
|
return Result.Ok(setmetatable(parsed :: Version, Semver))
|
|
end
|
|
|
|
local function prereleaseEq(leftPrerelease: PrereleaseVersion?, rightPrerelease: PrereleaseVersion?): boolean
|
|
if leftPrerelease == nil and rightPrerelease == nil then
|
|
return true
|
|
end
|
|
|
|
if leftPrerelease == nil or rightPrerelease == nil then
|
|
return false
|
|
end
|
|
|
|
return leftPrerelease.type == rightPrerelease.type and leftPrerelease.ordinal == rightPrerelease.ordinal
|
|
end
|
|
|
|
local function prereleaseLt(leftPrerelease: PrereleaseVersion?, rightPrerelase: PrereleaseVersion?): boolean
|
|
if not prereleaseEq(leftPrerelease, rightPrerelase) then
|
|
if leftPrerelease ~= nil and rightPrerelase ~= nil then
|
|
if leftPrerelease.type == rightPrerelase.type then
|
|
return leftPrerelease.ordinal:unwrapOr(0) < rightPrerelase.ordinal:unwrapOr(0)
|
|
end
|
|
|
|
return PRERELEASE_LEX_ORDER[leftPrerelease.type] < PRERELEASE_LEX_ORDER[rightPrerelase.type]
|
|
end
|
|
|
|
return not (leftPrerelease == nil and rightPrerelase ~= nil)
|
|
and (leftPrerelease ~= nil and rightPrerelase == nil)
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
local function prereleaseLe(leftPrerelease: PrereleaseVersion?, rightPrerelase: PrereleaseVersion?): boolean
|
|
return prereleaseLt(leftPrerelease, rightPrerelase) or prereleaseEq(leftPrerelease, rightPrerelase)
|
|
end
|
|
|
|
function Semver.__eq(left: SemverImpl, right: SemverImpl): boolean
|
|
return left.major == right.major
|
|
and left.minor == right.minor
|
|
and left.patch == right.patch
|
|
and prereleaseEq(left.prerelease:unwrapOrNil(), right.prerelease:unwrapOrNil())
|
|
end
|
|
|
|
function Semver.__lt(left: SemverImpl, right: SemverImpl): boolean
|
|
return if left.major ~= right.major
|
|
then left.major < right.major
|
|
elseif left.minor ~= right.minor then left.minor < right.minor
|
|
elseif left.patch ~= right.patch then left.patch < right.patch
|
|
else prereleaseLt(left.prerelease:unwrapOrNil(), right.prerelease:unwrapOrNil())
|
|
end
|
|
|
|
function Semver.__le(left: SemverImpl, right: SemverImpl): boolean
|
|
return if left.major ~= right.major
|
|
then left.major <= right.major
|
|
elseif left.minor ~= right.minor then left.minor <= right.minor
|
|
elseif left.patch ~= right.patch then left.patch <= right.patch
|
|
else prereleaseLe(left.prerelease:unwrapOrNil(), right.prerelease:unwrapOrNil())
|
|
end
|
|
|
|
function Semver.__tostring(self: SemverImpl): string
|
|
local base = `{self.major}.{self.minor}.{self.patch}`
|
|
local prerelease = self.prerelease
|
|
:map(function(prerelease: PrereleaseVersion)
|
|
return `-{prerelease.type}{prerelease.ordinal
|
|
:map(function(ord: number)
|
|
return `.{ord}`
|
|
end)
|
|
:unwrapOr("")}`
|
|
end)
|
|
:unwrapOr("")
|
|
local build = self.buildMetadata
|
|
:map(function(buildMetadata: string)
|
|
return `+{buildMetadata}`
|
|
end)
|
|
:unwrapOr("")
|
|
|
|
return base .. prerelease .. build
|
|
end
|
|
|
|
return Semver
|