From 0e7196438d8f856eecd7c90e160b79cbc8fb08dc Mon Sep 17 00:00:00 2001 From: Kelly Lin Date: Sun, 19 Feb 2023 22:33:57 +1100 Subject: feat(lua): add semver api --- runtime/lua/vim/_init_packages.lua | 3 + runtime/lua/vim/version.lua | 297 +++++++++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 runtime/lua/vim/version.lua (limited to 'runtime/lua/vim') diff --git a/runtime/lua/vim/_init_packages.lua b/runtime/lua/vim/_init_packages.lua index e3a442af5e..d032026796 100644 --- a/runtime/lua/vim/_init_packages.lua +++ b/runtime/lua/vim/_init_packages.lua @@ -69,6 +69,9 @@ setmetatable(vim, { t[key] = val return t[key] end + elseif key == 'version' then + t[key] = require('vim.version') + return t[key] end end, }) diff --git a/runtime/lua/vim/version.lua b/runtime/lua/vim/version.lua new file mode 100644 index 0000000000..49ef019295 --- /dev/null +++ b/runtime/lua/vim/version.lua @@ -0,0 +1,297 @@ +local M = {} + +---@private +--- Compares the prerelease component of the two versions. +---@param v1_parsed table Parsed version. +---@param v2_parsed table Parsed version. +---@return integer `-1` if `v1_parsed < v2_parsed`, `0` if `v1_parsed == v2_parsed`, `1` if `v1_parsed > v2_parsed`. +local function cmp_prerelease(v1_parsed, v2_parsed) + if v1_parsed.prerelease and not v2_parsed.prerelease then + return -1 + end + if not v1_parsed.prerelease and v2_parsed.prerelease then + return 1 + end + if not v1_parsed.prerelease and not v2_parsed.prerelease then + return 0 + end + + local v1_identifiers = vim.split(v1_parsed.prerelease, '.', { plain = true }) + local v2_identifiers = vim.split(v2_parsed.prerelease, '.', { plain = true }) + local i = 1 + local max = math.max(vim.tbl_count(v1_identifiers), vim.tbl_count(v2_identifiers)) + while i <= max do + local v1_identifier = v1_identifiers[i] + local v2_identifier = v2_identifiers[i] + if v1_identifier ~= v2_identifier then + local v1_num = tonumber(v1_identifier) + local v2_num = tonumber(v2_identifier) + local is_number = v1_num and v2_num + if is_number then + -- Number comparisons + if not v1_num and v2_num then + return -1 + end + if v1_num and not v2_num then + return 1 + end + if v1_num == v2_num then + return 0 + end + if v1_num > v2_num then + return 1 + end + if v1_num < v2_num then + return -1 + end + else + -- String comparisons + if v1_identifier and not v2_identifier then + return 1 + end + if not v1_identifier and v2_identifier then + return -1 + end + if v1_identifier < v2_identifier then + return -1 + end + if v1_identifier > v2_identifier then + return 1 + end + if v1_identifier == v2_identifier then + return 0 + end + end + end + i = i + 1 + end + + return 0 +end + +---@private +--- Compares the version core component of the two versions. +---@param v1_parsed table Parsed version. +---@param v2_parsed table Parsed version. +---@return integer `-1` if `v1_parsed < v2_parsed`, `0` if `v1_parsed == v2_parsed`, `1` if `v1_parsed > v2_parsed`. +local function cmp_version_core(v1_parsed, v2_parsed) + if + v1_parsed.major == v2_parsed.major + and v1_parsed.minor == v2_parsed.minor + and v1_parsed.patch == v2_parsed.patch + then + return 0 + end + + if + v1_parsed.major > v2_parsed.major + or v1_parsed.minor > v2_parsed.minor + or v1_parsed.patch > v2_parsed.patch + then + return 1 + end + + return -1 +end + +--- Compares two strings (`v1` and `v2`) in semver format. +---@param v1 string Version. +---@param v2 string Version to be compared with v1. +---@param opts table|nil Optional keyword arguments: +--- - strict (boolean): see `semver.parse` for details. Defaults to false. +---@return integer `-1` if `v1 < v2`, `0` if `v1 == v2`, `1` if `v1 > v2`. +function M.cmp(v1, v2, opts) + opts = opts or { strict = false } + local v1_parsed = M.parse(v1, opts) + local v2_parsed = M.parse(v2, opts) + + local result = cmp_version_core(v1_parsed, v2_parsed) + if result == 0 then + result = cmp_prerelease(v1_parsed, v2_parsed) + end + return result +end + +---@private +---@param labels string Prerelease and build component of semantic version string e.g. "-rc1+build.0". +---@return string|nil +local function parse_prerelease(labels) + -- This pattern matches "-(alpha)+build.15". + -- '^%-[%w%.]+$' + local result = labels:match('^%-([%w%.]+)+.+$') + if result then + return result + end + -- This pattern matches "-(alpha)". + result = labels:match('^%-([%w%.]+)') + if result then + return result + end + + return nil +end + +---@private +---@param labels string Prerelease and build component of semantic version string e.g. "-rc1+build.0". +---@return string|nil +local function parse_build(labels) + -- Pattern matches "-alpha+(build.15)". + local result = labels:match('^%-[%w%.]+%+([%w%.]+)$') + if result then + return result + end + + -- Pattern matches "+(build.15)". + result = labels:match('^%+([%w%.]+)$') + if result then + return result + end + + return nil +end + +---@private +--- Extracts the major, minor, patch and preprelease and build components from +--- `version`. +---@param version string Version string +local function extract_components_strict(version) + local major, minor, patch, prerelease_and_build = version:match('^v?(%d+)%.(%d+)%.(%d+)(.*)$') + return tonumber(major), tonumber(minor), tonumber(patch), prerelease_and_build +end + +---@private +--- Extracts the major, minor, patch and preprelease and build components from +--- `version`. When `minor` and `patch` components are not found (nil), coerce +--- them to 0. +---@param version string Version string +local function extract_components_loose(version) + local major, minor, patch, prerelease_and_build = version:match('^v?(%d+)%.?(%d*)%.?(%d*)(.*)$') + major = tonumber(major) + minor = tonumber(minor) or 0 + patch = tonumber(patch) or 0 + return major, minor, patch, prerelease_and_build +end + +---@private +--- Validates the prerelease and build string e.g. "-rc1+build.0". If the +--- prerelease, build or both are valid forms then it will return true, if it +--- is not of any valid form, it will return false. +---@param prerelease_and_build string +---@return boolean +local function is_prerelease_and_build_valid(prerelease_and_build) + if prerelease_and_build == '' then + return true + end + local has_build = parse_build(prerelease_and_build) ~= nil + local has_prerelease = parse_prerelease(prerelease_and_build) ~= nil + local has_prerelease_and_build = has_prerelease and has_build + return has_build or has_prerelease or has_prerelease_and_build +end + +---@private +---@param version string +---@return string +local function create_err_msg(version) + return string.format('invalid version: "%s"', version) +end + +--- Parses a semantically formatted version string into a table. +--- +--- Supports leading "v" and leading and trailing whitespace in the version +--- string. e.g. `" v1.0.1-rc1+build.2"` , `"1.0.1-rc1+build.2"`, `"v1.0.1-rc1+build.2"` +--- and `"v1.0.1-rc1+build.2 "` will be parsed as: +--- +--- `{ major = 1, minor = 0, patch = 1, prerelease = 'rc1', build = 'build.2' }` +--- +---@param version string Version string to be parsed. +---@param opts table|nil Optional keyword arguments: +--- - strict (boolean): when set to `true` an error will be thrown for version +--- strings that do not conform to the semver specification (v2.0.0) (see +--- semver.org/spec/v2.0.0.html for details). This means that +--- `semver.parse('v1.2)` will throw an error. When set to `false`, +--- `semver.parse('v1.2)` will coerce 'v1.2' to 'v1.2.0' and return the table: +--- `{ major = 1, minor = 2, patch = 0 }`. Defaults to false. +---@return table|nil parsed_version Parsed version table or `nil` if `version` is not valid. +function M.parse(version, opts) + if type(version) ~= 'string' then + error(create_err_msg(version)) + end + + opts = opts or { strict = false } + + version = vim.trim(version) + + local extract_components = opts.strict and extract_components_strict or extract_components_loose + local major, minor, patch, prerelease_and_build = extract_components(version) + + -- If major is nil then that means that the version does not begin with a + -- digit with or without a "v" prefix. + if major == nil or not is_prerelease_and_build_valid(prerelease_and_build) then + return nil + end + + local prerelease = nil + local build = nil + if prerelease_and_build ~= nil then + prerelease = parse_prerelease(prerelease_and_build) + build = parse_build(prerelease_and_build) + end + + return { + major = major, + minor = minor, + patch = patch, + prerelease = prerelease, + build = build, + } +end + +---@private +--- Throws an error if `version` cannot be parsed. +---@param version string +local function assert_version(version) + if M.parse(version) == nil then + error(create_err_msg(version)) + end +end + +---Returns `true` if `v1` are `v2` are equal versions. +---@param version_1 string +---@param version_2 string +---@return boolean +function M.eq(version_1, version_2) + assert_version(version_1) + assert_version(version_2) + + return M.cmp(version_1, version_2) == 0 +end + +---Returns `true` if `v1` is less than `v2`. +---@param version_1 string +---@param version_2 string +---@return boolean +function M.lt(version_1, version_2) + assert_version(version_1) + assert_version(version_2) + + return M.cmp(version_1, version_2) == -1 +end + +---Returns `true` if `v1` is greater than `v2`. +---@param version_1 string +---@param version_2 string +---@return boolean +function M.gt(version_1, version_2) + assert_version(version_1) + assert_version(version_2) + + return M.cmp(version_1, version_2) == 1 +end + +setmetatable(M, { + __call = function() + return vim.fn.api_info().version + end, +}) + +return M -- cgit From e31e49a8e3aac25e923dce15cc76dca4a447947f Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Mon, 6 Mar 2023 13:23:03 +0100 Subject: refactor(vim.version): cleanup - version.cmp(): assert valid version - add test for loading vim.version (the other tests use shared.lua in the test runner) - reduce test scopes, reword test descriptions --- runtime/lua/vim/_init_packages.lua | 8 +-- runtime/lua/vim/version.lua | 134 ++++++++++++++++--------------------- 2 files changed, 62 insertions(+), 80 deletions(-) (limited to 'runtime/lua/vim') diff --git a/runtime/lua/vim/_init_packages.lua b/runtime/lua/vim/_init_packages.lua index d032026796..57c0fc9122 100644 --- a/runtime/lua/vim/_init_packages.lua +++ b/runtime/lua/vim/_init_packages.lua @@ -51,7 +51,10 @@ end -- builtin functions which always should be available require('vim.shared') -vim._submodules = { inspect = true } +vim._submodules = { + inspect = true, + version = true, +} -- These are for loading runtime modules in the vim namespace lazily. setmetatable(vim, { @@ -69,9 +72,6 @@ setmetatable(vim, { t[key] = val return t[key] end - elseif key == 'version' then - t[key] = require('vim.version') - return t[key] end end, }) diff --git a/runtime/lua/vim/version.lua b/runtime/lua/vim/version.lua index 49ef019295..ddbe228244 100644 --- a/runtime/lua/vim/version.lua +++ b/runtime/lua/vim/version.lua @@ -1,23 +1,41 @@ local M = {} +---@private +---@param version string +---@return string +local function create_err_msg(version) + return string.format('invalid version: "%s"', version) +end + +---@private +--- Throws an error if `version` cannot be parsed. +---@param version string +local function assert_version(version, opt) + local rv = M.parse(version, opt) + if rv == nil then + error(create_err_msg(version)) + end + return rv +end + ---@private --- Compares the prerelease component of the two versions. ----@param v1_parsed table Parsed version. ----@param v2_parsed table Parsed version. ----@return integer `-1` if `v1_parsed < v2_parsed`, `0` if `v1_parsed == v2_parsed`, `1` if `v1_parsed > v2_parsed`. -local function cmp_prerelease(v1_parsed, v2_parsed) - if v1_parsed.prerelease and not v2_parsed.prerelease then +---@param v1 table Parsed version. +---@param v2 table Parsed version. +---@return integer `-1` if `v1 < v2`, `0` if `v1 == v2`, `1` if `v1 > v2`. +local function cmp_prerelease(v1, v2) + if v1.prerelease and not v2.prerelease then return -1 end - if not v1_parsed.prerelease and v2_parsed.prerelease then + if not v1.prerelease and v2.prerelease then return 1 end - if not v1_parsed.prerelease and not v2_parsed.prerelease then + if not v1.prerelease and not v2.prerelease then return 0 end - local v1_identifiers = vim.split(v1_parsed.prerelease, '.', { plain = true }) - local v2_identifiers = vim.split(v2_parsed.prerelease, '.', { plain = true }) + local v1_identifiers = vim.split(v1.prerelease, '.', { plain = true }) + local v2_identifiers = vim.split(v2.prerelease, '.', { plain = true }) local i = 1 local max = math.max(vim.tbl_count(v1_identifiers), vim.tbl_count(v2_identifiers)) while i <= max do @@ -71,23 +89,15 @@ end ---@private --- Compares the version core component of the two versions. ----@param v1_parsed table Parsed version. ----@param v2_parsed table Parsed version. ----@return integer `-1` if `v1_parsed < v2_parsed`, `0` if `v1_parsed == v2_parsed`, `1` if `v1_parsed > v2_parsed`. -local function cmp_version_core(v1_parsed, v2_parsed) - if - v1_parsed.major == v2_parsed.major - and v1_parsed.minor == v2_parsed.minor - and v1_parsed.patch == v2_parsed.patch - then +---@param v1 table Parsed version. +---@param v2 table Parsed version. +---@return integer `-1` if `v1 < v2`, `0` if `v1 == v2`, `1` if `v1 > v2`. +local function cmp_version_core(v1, v2) + if v1.major == v2.major and v1.minor == v2.minor and v1.patch == v2.patch then return 0 end - if - v1_parsed.major > v2_parsed.major - or v1_parsed.minor > v2_parsed.minor - or v1_parsed.patch > v2_parsed.patch - then + if v1.major > v2.major or v1.minor > v2.minor or v1.patch > v2.patch then return 1 end @@ -96,14 +106,14 @@ end --- Compares two strings (`v1` and `v2`) in semver format. ---@param v1 string Version. ----@param v2 string Version to be compared with v1. +---@param v2 string Version to compare with v1. ---@param opts table|nil Optional keyword arguments: --- - strict (boolean): see `semver.parse` for details. Defaults to false. ---@return integer `-1` if `v1 < v2`, `0` if `v1 == v2`, `1` if `v1 > v2`. function M.cmp(v1, v2, opts) opts = opts or { strict = false } - local v1_parsed = M.parse(v1, opts) - local v2_parsed = M.parse(v2, opts) + local v1_parsed = assert_version(v1, opts) + local v2_parsed = assert_version(v2, opts) local result = cmp_version_core(v1_parsed, v2_parsed) if result == 0 then @@ -188,30 +198,20 @@ local function is_prerelease_and_build_valid(prerelease_and_build) return has_build or has_prerelease or has_prerelease_and_build end ----@private ----@param version string ----@return string -local function create_err_msg(version) - return string.format('invalid version: "%s"', version) -end - ---- Parses a semantically formatted version string into a table. ---- ---- Supports leading "v" and leading and trailing whitespace in the version ---- string. e.g. `" v1.0.1-rc1+build.2"` , `"1.0.1-rc1+build.2"`, `"v1.0.1-rc1+build.2"` ---- and `"v1.0.1-rc1+build.2 "` will be parsed as: +--- Parses a semantic version string. --- ---- `{ major = 1, minor = 0, patch = 1, prerelease = 'rc1', build = 'build.2' }` +--- Ignores leading "v" and surrounding whitespace, e.g. " v1.0.1-rc1+build.2", +--- "1.0.1-rc1+build.2", "v1.0.1-rc1+build.2" and "v1.0.1-rc1+build.2 " are all parsed as: +---
+---   { major = 1, minor = 0, patch = 1, prerelease = "rc1", build = "build.2" }
+--- 
--- ---@param version string Version string to be parsed. ---@param opts table|nil Optional keyword arguments: ---- - strict (boolean): when set to `true` an error will be thrown for version ---- strings that do not conform to the semver specification (v2.0.0) (see ---- semver.org/spec/v2.0.0.html for details). This means that ---- `semver.parse('v1.2)` will throw an error. When set to `false`, ---- `semver.parse('v1.2)` will coerce 'v1.2' to 'v1.2.0' and return the table: ---- `{ major = 1, minor = 2, patch = 0 }`. Defaults to false. ----@return table|nil parsed_version Parsed version table or `nil` if `version` is not valid. +--- - strict (boolean): Default false. If `true`, no coercion is attempted on +--- input not strictly conforming to semver v2.0.0 +--- (https://semver.org/spec/v2.0.0.html). E.g. `parse("v1.2")` returns nil. +---@return table|nil parsed_version Parsed version table or `nil` if `version` is invalid. function M.parse(version, opts) if type(version) ~= 'string' then error(create_err_msg(version)) @@ -246,46 +246,28 @@ function M.parse(version, opts) } end ----@private ---- Throws an error if `version` cannot be parsed. ----@param version string -local function assert_version(version) - if M.parse(version) == nil then - error(create_err_msg(version)) - end -end - ---Returns `true` if `v1` are `v2` are equal versions. ----@param version_1 string ----@param version_2 string +---@param v1 string +---@param v2 string ---@return boolean -function M.eq(version_1, version_2) - assert_version(version_1) - assert_version(version_2) - - return M.cmp(version_1, version_2) == 0 +function M.eq(v1, v2) + return M.cmp(v1, v2) == 0 end ---Returns `true` if `v1` is less than `v2`. ----@param version_1 string ----@param version_2 string +---@param v1 string +---@param v2 string ---@return boolean -function M.lt(version_1, version_2) - assert_version(version_1) - assert_version(version_2) - - return M.cmp(version_1, version_2) == -1 +function M.lt(v1, v2) + return M.cmp(v1, v2) == -1 end ---Returns `true` if `v1` is greater than `v2`. ----@param version_1 string ----@param version_2 string +---@param v1 string +---@param v2 string ---@return boolean -function M.gt(version_1, version_2) - assert_version(version_1) - assert_version(version_2) - - return M.cmp(version_1, version_2) == 1 +function M.gt(v1, v2) + return M.cmp(v1, v2) == 1 end setmetatable(M, { -- cgit From 74ffebf8ec725a25c2ae1dde81cf26b83fc7ae61 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Mon, 6 Mar 2023 15:08:22 +0100 Subject: fix(vim.version): incorrect version.cmp() Problem: If majorminor, cmp_version_core returns 1 Solution: - Fix logic in cmp_version_core - Delete most eq()/gt()/lt() tests, they are redundant. --- runtime/lua/vim/version.lua | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) (limited to 'runtime/lua/vim') diff --git a/runtime/lua/vim/version.lua b/runtime/lua/vim/version.lua index ddbe228244..35629c461f 100644 --- a/runtime/lua/vim/version.lua +++ b/runtime/lua/vim/version.lua @@ -3,8 +3,11 @@ local M = {} ---@private ---@param version string ---@return string -local function create_err_msg(version) - return string.format('invalid version: "%s"', version) +local function create_err_msg(v) + if type(v) == 'string' then + return string.format('invalid version: "%s"', tostring(v)) + end + return string.format('invalid version: %s (%s)', tostring(v), type(v)) end ---@private @@ -20,9 +23,6 @@ end ---@private --- Compares the prerelease component of the two versions. ----@param v1 table Parsed version. ----@param v2 table Parsed version. ----@return integer `-1` if `v1 < v2`, `0` if `v1 == v2`, `1` if `v1 > v2`. local function cmp_prerelease(v1, v2) if v1.prerelease and not v2.prerelease then return -1 @@ -88,19 +88,17 @@ local function cmp_prerelease(v1, v2) end ---@private ---- Compares the version core component of the two versions. ----@param v1 table Parsed version. ----@param v2 table Parsed version. ----@return integer `-1` if `v1 < v2`, `0` if `v1 == v2`, `1` if `v1 > v2`. local function cmp_version_core(v1, v2) if v1.major == v2.major and v1.minor == v2.minor and v1.patch == v2.patch then return 0 end - - if v1.major > v2.major or v1.minor > v2.minor or v1.patch > v2.patch then + if + v1.major > v2.major + or (v1.major == v2.major and v1.minor > v2.minor) + or (v1.major == v2.major and v1.minor == v2.minor and v1.patch > v2.patch) + then return 1 end - return -1 end -- cgit