diff options
author | Justin M. Keyes <justinkz@gmail.com> | 2023-03-06 10:04:13 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-03-06 10:04:13 -0500 |
commit | 6969d3d7491fc2f10d80309b26dd0c26d211b1b3 (patch) | |
tree | a6ef1d2c0195f9dd06109e0d977cb382c9b428df | |
parent | f9a46391ab5961fe6c6b7d1efdc96befdd495c11 (diff) | |
parent | 74ffebf8ec725a25c2ae1dde81cf26b83fc7ae61 (diff) | |
download | rneovim-6969d3d7491fc2f10d80309b26dd0c26d211b1b3.tar.gz rneovim-6969d3d7491fc2f10d80309b26dd0c26d211b1b3.tar.bz2 rneovim-6969d3d7491fc2f10d80309b26dd0c26d211b1b3.zip |
Merge #22325 vim.version semver parser
-rw-r--r-- | runtime/doc/lua.txt | 67 | ||||
-rw-r--r-- | runtime/doc/news.txt | 3 | ||||
-rw-r--r-- | runtime/lua/vim/_init_packages.lua | 5 | ||||
-rw-r--r-- | runtime/lua/vim/version.lua | 277 | ||||
-rwxr-xr-x | scripts/gen_vimdoc.py | 3 | ||||
-rw-r--r-- | src/nvim/lua/executor.c | 15 | ||||
-rw-r--r-- | test/functional/lua/version_spec.lua | 339 |
7 files changed, 693 insertions, 16 deletions
diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 1eb5ab41e6..697cd86e8a 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -2497,4 +2497,71 @@ trust({opts}) *vim.secure.trust()* • true and full path of target file if operation was successful • false and error message on failure + +============================================================================== +Lua module: version *lua-version* + +cmp({v1}, {v2}, {opts}) *vim.version.cmp()* + Compares two strings ( `v1` and `v2` ) in semver format. + + Parameters: ~ + • {v1} (string) Version. + • {v2} (string) Version to compare with v1. + • {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`. + +eq({v1}, {v2}) *vim.version.eq()* + Returns `true` if `v1` are `v2` are equal versions. + + Parameters: ~ + • {v1} (string) + • {v2} (string) + + Return: ~ + (boolean) + +gt({v1}, {v2}) *vim.version.gt()* + Returns `true` if `v1` is greater than `v2` . + + Parameters: ~ + • {v1} (string) + • {v2} (string) + + Return: ~ + (boolean) + +lt({v1}, {v2}) *vim.version.lt()* + Returns `true` if `v1` is less than `v2` . + + Parameters: ~ + • {v1} (string) + • {v2} (string) + + Return: ~ + (boolean) + +parse({version}, {opts}) *vim.version.parse()* + Parses a semantic version string. + + 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" } +< + + Parameters: ~ + • {version} (string) Version string to be parsed. + • {opts} (table|nil) Optional keyword arguments: + • 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. + vim:tw=78:ts=8:sw=4:sts=4:et:ft=help:norl: diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 415195e27e..28fdaa770d 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -55,6 +55,9 @@ NEW FEATURES *news-features* The following new APIs or features were added. +• Added |vim.version| for parsing and comparing version strings conforming to + the semver specification, see |lua-version|. + • A new environment variable named NVIM_APPNAME enables configuring the directories where Neovim should find its configuration and state files. See `:help $NVIM_APPNAME` . diff --git a/runtime/lua/vim/_init_packages.lua b/runtime/lua/vim/_init_packages.lua index e3a442af5e..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, { diff --git a/runtime/lua/vim/version.lua b/runtime/lua/vim/version.lua new file mode 100644 index 0000000000..35629c461f --- /dev/null +++ b/runtime/lua/vim/version.lua @@ -0,0 +1,277 @@ +local M = {} + +---@private +---@param version string +---@return string +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 +--- 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. +local function cmp_prerelease(v1, v2) + if v1.prerelease and not v2.prerelease then + return -1 + end + if not v1.prerelease and v2.prerelease then + return 1 + end + if not v1.prerelease and not v2.prerelease then + return 0 + end + + 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 + 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 +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.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 + +--- Compares two strings (`v1` and `v2`) in semver format. +---@param v1 string Version. +---@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 = assert_version(v1, opts) + local v2_parsed = assert_version(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 + +--- Parses a semantic version string. +--- +--- 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: +--- <pre> +--- { major = 1, minor = 0, patch = 1, prerelease = "rc1", build = "build.2" } +--- </pre> +--- +---@param version string Version string to be parsed. +---@param opts table|nil Optional keyword arguments: +--- - 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)) + 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 + +---Returns `true` if `v1` are `v2` are equal versions. +---@param v1 string +---@param v2 string +---@return boolean +function M.eq(v1, v2) + return M.cmp(v1, v2) == 0 +end + +---Returns `true` if `v1` is less than `v2`. +---@param v1 string +---@param v2 string +---@return boolean +function M.lt(v1, v2) + return M.cmp(v1, v2) == -1 +end + +---Returns `true` if `v1` is greater than `v2`. +---@param v1 string +---@param v2 string +---@return boolean +function M.gt(v1, v2) + return M.cmp(v1, v2) == 1 +end + +setmetatable(M, { + __call = function() + return vim.fn.api_info().version + end, +}) + +return M diff --git a/scripts/gen_vimdoc.py b/scripts/gen_vimdoc.py index 0164278b34..1e85fa49e9 100755 --- a/scripts/gen_vimdoc.py +++ b/scripts/gen_vimdoc.py @@ -152,6 +152,7 @@ CONFIG = { 'keymap.lua', 'fs.lua', 'secure.lua', + 'version.lua', ], 'files': [ 'runtime/lua/vim/_editor.lua', @@ -162,6 +163,7 @@ CONFIG = { 'runtime/lua/vim/keymap.lua', 'runtime/lua/vim/fs.lua', 'runtime/lua/vim/secure.lua', + 'runtime/lua/vim/version.lua', 'runtime/lua/vim/_inspector.lua', ], 'file_patterns': '*.lua', @@ -192,6 +194,7 @@ CONFIG = { 'keymap': 'vim.keymap', 'fs': 'vim.fs', 'secure': 'vim.secure', + 'version': 'vim.version', }, 'append_only': [ 'shared.lua', diff --git a/src/nvim/lua/executor.c b/src/nvim/lua/executor.c index bb461a7f13..5ec91aebb0 100644 --- a/src/nvim/lua/executor.c +++ b/src/nvim/lua/executor.c @@ -165,17 +165,6 @@ static int nlua_pcall(lua_State *lstate, int nargs, int nresults) return status; } -/// Gets the version of the current Nvim build. -/// -/// @param lstate Lua interpreter state. -static int nlua_nvim_version(lua_State *const lstate) FUNC_ATTR_NONNULL_ALL -{ - Dictionary version = version_dict(); - nlua_push_Dictionary(lstate, version, true); - api_free_dictionary(version); - return 1; -} - static void nlua_luv_error_event(void **argv) { char *error = (char *)argv[0]; @@ -739,10 +728,6 @@ static bool nlua_state_init(lua_State *const lstate) FUNC_ATTR_NONNULL_ALL // vim.types, vim.type_idx, vim.val_idx nlua_init_types(lstate); - // neovim version - lua_pushcfunction(lstate, &nlua_nvim_version); - lua_setfield(lstate, -2, "version"); - // schedule lua_pushcfunction(lstate, &nlua_schedule); lua_setfield(lstate, -2, "schedule"); diff --git a/test/functional/lua/version_spec.lua b/test/functional/lua/version_spec.lua new file mode 100644 index 0000000000..b68727ca77 --- /dev/null +++ b/test/functional/lua/version_spec.lua @@ -0,0 +1,339 @@ +local helpers = require('test.functional.helpers')(after_each) +local clear = helpers.clear +local eq = helpers.eq +local exec_lua = helpers.exec_lua +local matches = helpers.matches +local pcall_err = helpers.pcall_err + +local version = require('vim.version') + +local function quote_empty(s) + return tostring(s) == '' and '""' or tostring(s) +end + +describe('version', function() + it('package', function() + clear() + eq({ major = 42, minor = 3, patch = 99 }, exec_lua("return vim.version.parse('v42.3.99')")) + end) + + describe('cmp()', function() + local testcases = { + { + desc = '(v1 < v2)', + v1 = 'v0.0.99', + v2 = 'v9.0.0', + want = -1, + }, + { + desc = '(v1 < v2)', + v1 = 'v0.4.0', + v2 = 'v0.9.99', + want = -1, + }, + { + desc = '(v1 < v2)', + v1 = 'v0.2.8', + v2 = 'v1.0.9', + want = -1, + }, + { + desc = '(v1 == v2)', + v1 = 'v0.0.0', + v2 = 'v0.0.0', + want = 0, + }, + { + desc = '(v1 > v2)', + v1 = 'v9.0.0', + v2 = 'v0.9.0', + want = 1, + }, + { + desc = '(v1 > v2)', + v1 = 'v0.9.0', + v2 = 'v0.0.0', + want = 1, + }, + { + desc = '(v1 > v2)', + v1 = 'v0.0.9', + v2 = 'v0.0.0', + want = 1, + }, + { + desc = '(v1 < v2) when v1 has prerelease', + v1 = 'v1.0.0-alpha', + v2 = 'v1.0.0', + want = -1, + }, + { + desc = '(v1 > v2) when v2 has prerelease', + v1 = '1.0.0', + v2 = '1.0.0-alpha', + want = 1, + }, + { + desc = '(v1 > v2) when v1 has a higher number identifier', + v1 = '1.0.0-2', + v2 = '1.0.0-1', + want = 1, + }, + { + desc = '(v1 < v2) when v2 has a higher number identifier', + v1 = '1.0.0-2', + v2 = '1.0.0-9', + want = -1, + }, + { + desc = '(v1 < v2) when v2 has more identifiers', + v1 = '1.0.0-2', + v2 = '1.0.0-2.0', + want = -1, + }, + { + desc = '(v1 > v2) when v1 has more identifiers', + v1 = '1.0.0-2.0', + v2 = '1.0.0-2', + want = 1, + }, + { + desc = '(v1 == v2) when v2 has same numeric identifiers', + v1 = '1.0.0-2.0', + v2 = '1.0.0-2.0', + want = 0, + }, + { + desc = '(v1 == v2) when v2 has same alphabet identifiers', + v1 = '1.0.0-alpha', + v2 = '1.0.0-alpha', + want = 0, + }, + { + desc = '(v1 < v2) when v2 has an alphabet identifier with higher ASCII sort order', + v1 = '1.0.0-alpha', + v2 = '1.0.0-beta', + want = -1, + }, + { + desc = '(v1 > v2) when v1 has an alphabet identifier with higher ASCII sort order', + v1 = '1.0.0-beta', + v2 = '1.0.0-alpha', + want = 1, + }, + { + desc = '(v1 < v2) when v2 has prerelease and number identifer', + v1 = '1.0.0-alpha', + v2 = '1.0.0-alpha.1', + want = -1, + }, + { + desc = '(v1 > v2) when v1 has prerelease and number identifer', + v1 = '1.0.0-alpha.1', + v2 = '1.0.0-alpha', + want = 1, + }, + { + desc = '(v1 > v2) when v1 has an additional alphabet identifier', + v1 = '1.0.0-alpha.beta', + v2 = '1.0.0-alpha', + want = 1, + }, + { + desc = '(v1 < v2) when v2 has an additional alphabet identifier', + v1 = '1.0.0-alpha', + v2 = '1.0.0-alpha.beta', + want = -1, + }, + { + desc = '(v1 < v2) when v2 has an a first alphabet identifier with higher precedence', + v1 = '1.0.0-alpha.beta', + v2 = '1.0.0-beta', + want = -1, + }, + { + desc = '(v1 > v2) when v1 has an a first alphabet identifier with higher precedence', + v1 = '1.0.0-beta', + v2 = '1.0.0-alpha.beta', + want = 1, + }, + { + desc = '(v1 < v2) when v2 has an additional number identifer', + v1 = '1.0.0-beta', + v2 = '1.0.0-beta.2', + want = -1, + }, + { + desc = '(v1 < v2) when v2 has same first alphabet identifier but has a higher number identifer', + v1 = '1.0.0-beta.2', + v2 = '1.0.0-beta.11', + want = -1, + }, + { + desc = '(v1 < v2) when v2 has higher alphabet precedence', + v1 = '1.0.0-beta.11', + v2 = '1.0.0-rc.1', + want = -1, + }, + } + for _, tc in ipairs(testcases) do + it( + string.format('%d %s (v1 = %s, v2 = %s)', tc.want, tc.desc, tc.v1, tc.v2), + function() + eq(tc.want, version.cmp(tc.v1, tc.v2, { strict = true })) + end + ) + end + end) + + describe('parse()', function() + describe('strict=true', function() + local testcases = { + { + desc = 'version without leading "v"', + version = '10.20.123', + want = { + major = 10, + minor = 20, + patch = 123, + prerelease = nil, + build = nil, + }, + }, + { + desc = 'valid version with leading "v"', + version = 'v1.2.3', + want = { major = 1, minor = 2, patch = 3 }, + }, + { + desc = 'valid version with leading "v" and whitespace', + version = ' v1.2.3', + want = { major = 1, minor = 2, patch = 3 }, + }, + { + desc = 'valid version with leading "v" and trailing whitespace', + version = 'v1.2.3 ', + want = { major = 1, minor = 2, patch = 3 }, + }, + { + desc = 'version with prerelease', + version = '1.2.3-alpha', + want = { major = 1, minor = 2, patch = 3, prerelease = 'alpha' }, + }, + { + desc = 'version with prerelease with additional identifiers', + version = '1.2.3-alpha.1', + want = { major = 1, minor = 2, patch = 3, prerelease = 'alpha.1' }, + }, + { + desc = 'version with build', + version = '1.2.3+build.15', + want = { major = 1, minor = 2, patch = 3, build = 'build.15' }, + }, + { + desc = 'version with prerelease and build', + version = '1.2.3-rc1+build.15', + want = { + major = 1, + minor = 2, + patch = 3, + prerelease = 'rc1', + build = 'build.15', + }, + }, + } + for _, tc in ipairs(testcases) do + it( + string.format('for %q: version = %q', tc.desc, tc.version), + function() + eq(tc.want, version.parse(tc.version, { strict = true })) + end + ) + end + end) + + describe('strict=false', function() + local testcases = { + { + desc = 'version missing patch version', + version = '1.2', + want = { major = 1, minor = 2, patch = 0 }, + }, + { + desc = 'version missing minor and patch version', + version = '1', + want = { major = 1, minor = 0, patch = 0 }, + }, + { + desc = 'version missing patch version with prerelease', + version = '1.1-0', + want = { major = 1, minor = 1, patch = 0, prerelease = '0' }, + }, + { + desc = 'version missing minor and patch version with prerelease', + version = '1-1.0', + want = { major = 1, minor = 0, patch = 0, prerelease = '1.0' }, + }, + } + for _, tc in ipairs(testcases) do + it( + string.format('for %q: version = %q', tc.desc, tc.version), + function() + eq(tc.want, version.parse(tc.version, { strict = false })) + end + ) + end + end) + + describe('invalid semver', function() + local testcases = { + { desc = 'a word', version = 'foo' }, + { desc = 'empty string', version = '' }, + { desc = 'trailing period character', version = '0.0.0.' }, + { desc = 'leading period character', version = '.0.0.0' }, + { desc = 'negative major version', version = '-1.0.0' }, + { desc = 'negative minor version', version = '0.-1.0' }, + { desc = 'negative patch version', version = '0.0.-1' }, + { desc = 'leading invalid string', version = 'foobar1.2.3' }, + { desc = 'trailing invalid string', version = '1.2.3foobar' }, + { desc = 'an invalid prerelease', version = '1.2.3-%?' }, + { desc = 'an invalid build', version = '1.2.3+%?' }, + { desc = 'build metadata before prerelease', version = '1.2.3+build.0-rc1' }, + } + for _, tc in ipairs(testcases) do + it(string.format('(%s): %s', tc.desc, quote_empty(tc.version)), function() + eq(nil, version.parse(tc.version, { strict = true })) + end) + end + end) + + describe('invalid shape', function() + local testcases = { + { desc = 'no parameters' }, + { desc = 'nil', version = nil }, + { desc = 'number', version = 0 }, + { desc = 'float', version = 0.01 }, + { desc = 'table', version = {} }, + } + for _, tc in ipairs(testcases) do + it(string.format('(%s): %s', tc.desc, tostring(tc.version)), function() + local expected = string.format(type(tc.version) == 'string' + and 'invalid version: "%s"' or 'invalid version: %s', tostring(tc.version)) + matches(expected, pcall_err(version.parse, tc.version, { strict = true })) + end) + end + end) + end) + + it('lt()', function() + eq(true, version.lt('1', '2')) + end) + + it('gt()', function() + eq(true, version.gt('2', '1')) + end) + + it('eq()', function() + eq(true, version.eq('2', '2')) + end) +end) |