From f54c24a4fac6d261dc6ebd72d64aa8ceaab9aa12 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Mon, 28 Nov 2022 07:31:29 +0100 Subject: [PATCH] feat: added full semver and range parsing --- .neoconf.json | 15 +++ lua/lazy/manage/semver.lua | 191 +++++++++++++++++++++++++++++++++++ tests/manage/semver_spec.lua | 97 ++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 .neoconf.json create mode 100644 lua/lazy/manage/semver.lua create mode 100644 tests/manage/semver_spec.lua diff --git a/.neoconf.json b/.neoconf.json new file mode 100644 index 00000000..67828bb1 --- /dev/null +++ b/.neoconf.json @@ -0,0 +1,15 @@ +{ + "neodev": { + "library": { + "plugins": [ + "plenary.nvim" + ] + } + }, + "lspconfig": { + "sumneko_lua": { + "Lua.runtime.version": "LuaJIT", + "Lua.workspace.checkThirdParty": false + } + } +} diff --git a/lua/lazy/manage/semver.lua b/lua/lazy/manage/semver.lua new file mode 100644 index 00000000..4e588810 --- /dev/null +++ b/lua/lazy/manage/semver.lua @@ -0,0 +1,191 @@ +local M = {} + +---@class Semver +---@field [1] number +---@field [2] number +---@field [3] number +---@field major number +---@field minor number +---@field patch number +---@field prerelease? string +---@field build? string +local Semver = {} +Semver.__index = Semver + +function Semver:__index(key) + return type(key) == "number" and ({ self.major, self.minor, self.patch })[key] or Semver[key] +end + +function Semver:__newindex(key, value) + if key == 1 then + self.major = value + elseif key == 2 then + self.minor = value + elseif key == 3 then + self.patch = value + else + rawset(self, key, value) + end +end + +---@param other Semver +function Semver:__eq(other) + for i = 1, 3 do + if self[i] ~= other[i] then + return false + end + end + return self.prerelease == other.prerelease +end + +function Semver:__tostring() + local ret = table.concat({ self.major, self.minor, self.patch }, ".") + if self.prerelease then + ret = ret .. "-" .. self.prerelease + end + if self.build then + ret = ret .. "+" .. self.build + end + return ret +end + +---@param other Semver +function Semver:__lt(other) + for i = 1, 3 do + if self[i] > other[i] then + return false + elseif self[i] < other[i] then + return true + end + end + if self.prerelease and not other.prerelease then + return true + end + if other.prerelease and not self.prerelease then + return false + end + return (self.prerelease or "") < (other.prerelease or "") +end + +---@param other Semver +function Semver:__le(other) + return self < other or self == other +end + +---@param version string|number[] +---@return Semver? +function M.version(version) + if type(version) == "table" then + return setmetatable({ + major = version[1] or 0, + minor = version[2] or 0, + patch = version[3] or 0, + }, Semver) + end + local major, minor, patch, prerelease, build = version:match("^v?(%d+)%.?(%d*)%.?(%d*)%-?([^+]*)+?(.*)$") + if major then + return setmetatable({ + major = tonumber(major), + minor = minor == "" and 0 or tonumber(minor), + patch = patch == "" and 0 or tonumber(patch), + prerelease = prerelease ~= "" and prerelease or nil, + build = build ~= "" and build or nil, + }, Semver) + end +end + +---@generic T: Semver +---@param versions T[] +---@return T? +function M.last(versions) + local last = versions[1] + for i = 2, #versions do + if versions[i] > last then + last = versions[i] + end + end + return last +end + +---@class SemverRange +---@field from Semver +---@field to? Semver +local Range = {} + +---@param version string|Semver +function Range:matches(version) + if type(version) == "string" then + ---@diagnostic disable-next-line: cast-local-type + version = M.version(version) + end + if version then + if version.prerelease ~= self.from.prerelease then + return false + end + return version >= self.from and (self.to == nil or version < self.to) + end +end + +---@param spec string +function M.range(spec) + if spec == "*" or spec == "" then + return setmetatable({ from = M.version("0.0.0") }, { __index = Range }) + end + + ---@type number? + local hyphen = spec:find(" - ", 1, true) + if hyphen then + local a = spec:sub(1, hyphen - 1) + local b = spec:sub(hyphen + 3) + local parts = vim.split(b, ".", { plain = true }) + local ra = M.range(a) + local rb = M.range(b) + return setmetatable({ + from = ra and ra.from, + to = rb and (#parts == 3 and rb.from or rb.to), + }, { __index = Range }) + end + ---@type string, string + local mods, version = spec:lower():match("^([%^=>~]*)(.*)$") + version = version:gsub("%.[%*x]", "") + local parts = vim.split(version:gsub("%-.*", ""), ".", { plain = true }) + if #parts < 3 and mods == "" then + mods = "~" + end + + local semver = M.version(version) + if semver then + local from = semver + local to = vim.deepcopy(semver) + if mods == "" or mods == "=" then + to.patch = to.patch + 1 + elseif mods == ">" then + from.patch = from.patch + 1 + to = nil + elseif mods == ">=" then + to = nil + elseif mods == "~" then + if #parts >= 2 then + to[2] = to[2] + 1 + to[3] = 0 + else + to[1] = to[1] + 1 + to[2] = 0 + to[3] = 0 + end + elseif mods == "^" then + for i = 1, 3 do + if to[i] ~= 0 then + to[i] = to[i] + 1 + for j = i + 1, 3 do + to[j] = 0 + end + break + end + end + end + return setmetatable({ from = from, to = to }, { __index = Range }) + end +end + +return M diff --git a/tests/manage/semver_spec.lua b/tests/manage/semver_spec.lua new file mode 100644 index 00000000..3b96d994 --- /dev/null +++ b/tests/manage/semver_spec.lua @@ -0,0 +1,97 @@ +local Semver = require("lazy.manage.semver") + +local function v(version) + return Semver.version(version) +end + +describe("semver version", function() + local tests = { + ["v1.2.3"] = { major = 1, minor = 2, patch = 3 }, + ["v1.2"] = { major = 1, minor = 2, patch = 0 }, + ["v1.2.3-prerelease"] = { major = 1, minor = 2, patch = 3, prerelease = "prerelease" }, + ["v1.2-prerelease"] = { major = 1, minor = 2, patch = 0, prerelease = "prerelease" }, + ["v1.2.3-prerelease+build"] = { major = 1, minor = 2, patch = 3, prerelease = "prerelease", build = "build" }, + ["1.2.3+build"] = { major = 1, minor = 2, patch = 3, build = "build" }, + } + for input, output in pairs(tests) do + it("correctly parses " .. input, function() + assert.same(output, v(input)) + end) + end +end) + +describe("semver range", function() + local tests = { + ["1.2.3"] = { from = { 1, 2, 3 }, to = { 1, 2, 4 } }, + ["1.2"] = { from = { 1, 2, 0 }, to = { 1, 3, 0 } }, + ["=1.2.3"] = { from = { 1, 2, 3 }, to = { 1, 2, 4 } }, + [">1.2.3"] = { from = { 1, 2, 4 } }, + [">=1.2.3"] = { from = { 1, 2, 3 } }, + ["~1.2.3"] = { from = { 1, 2, 3 }, to = { 1, 3, 0 } }, + ["^1.2.3"] = { from = { 1, 2, 3 }, to = { 2, 0, 0 } }, + ["^0.2.3"] = { from = { 0, 2, 3 }, to = { 0, 3, 0 } }, + ["^0.0.1"] = { from = { 0, 0, 1 }, to = { 0, 0, 2 } }, + ["^1.2"] = { from = { 1, 2, 0 }, to = { 2, 0, 0 } }, + ["~1.2"] = { from = { 1, 2, 0 }, to = { 1, 3, 0 } }, + ["~1"] = { from = { 1, 0, 0 }, to = { 2, 0, 0 } }, + ["^1"] = { from = { 1, 0, 0 }, to = { 2, 0, 0 } }, + ["1.*"] = { from = { 1, 0, 0 }, to = { 2, 0, 0 } }, + ["1"] = { from = { 1, 0, 0 }, to = { 2, 0, 0 } }, + ["1.x"] = { from = { 1, 0, 0 }, to = { 2, 0, 0 } }, + ["1.2.x"] = { from = { 1, 2, 0 }, to = { 1, 3, 0 } }, + ["1.2.*"] = { from = { 1, 2, 0 }, to = { 1, 3, 0 } }, + ["*"] = { from = { 0, 0, 0 } }, + ["1.2 - 2.3.0"] = { from = { 1, 2, 0 }, to = { 2, 3, 0 } }, + ["1.2.3 - 2.3.4"] = { from = { 1, 2, 3 }, to = { 2, 3, 4 } }, + ["1.2.3 - 2"] = { from = { 1, 2, 3 }, to = { 3, 0, 0 } }, + } + for input, output in pairs(tests) do + output.from = v(output.from) + output.to = output.to and v(output.to) + + local range = Semver.range(input) + it("correctly parses " .. input, function() + assert.same(output, range) + end) + + it("from in range " .. input, function() + assert(range:matches(output.from)) + end) + + it("from - 1 not in range " .. input, function() + local lower = vim.deepcopy(range.from) + lower.major = lower.major - 1 + assert(not range:matches(lower)) + end) + + it("to not in range " .. input .. " to:" .. tostring(range.to), function() + if range.to then + assert(not (range.to < range.to)) + assert(not range:matches(range.to)) + end + end) + end + + it("handles prereleass", function() + assert(not Semver.range("1.2.3"):matches("1.2.3-alpha")) + assert(Semver.range("1.2.3-alpha"):matches("1.2.3-alpha")) + assert(not Semver.range("1.2.3-alpha"):matches("1.2.3-beta")) + end) +end) + +describe("semver order", function() + it("is correct", function() + assert(v("v1.2.3") == v("1.2.3")) + assert(not (v("v1.2.3") < v("1.2.3"))) + assert(v("v1.2.3") > v("1.2.3-prerelease")) + assert(v("v1.2.3-alpha") < v("1.2.3-beta")) + assert(v("v1.2.3-prerelease") < v("1.2.3")) + assert(v("v1.2.3") >= v("1.2.3")) + assert(v("v1.2.3") >= v("1.0.3")) + assert(v("v1.2.3") >= v("1.2.2")) + assert(v("v1.2.3") > v("1.2.2")) + assert(v("v1.2.3") > v("1.0.3")) + assert.same(Semver.last({ v("1.2.3"), v("2.0.0") }), v("2.0.0")) + assert.same(Semver.last({ v("2.0.0"), v("1.2.3") }), v("2.0.0")) + end) +end)