Merge #34715 vim.version improvements

This commit is contained in:
Justin M. Keyes
2025-07-01 04:19:42 -07:00
committed by GitHub
4 changed files with 276 additions and 70 deletions

View File

@ -3902,6 +3902,7 @@ versions (1.2.3-rc1) are not matched. >
>1.2.3 greater than 1.2.3
<1.2.3 before 1.2.3
>=1.2.3 at least 1.2.3
<=1.2.3 at most 1.2.3
~1.2.3 is >=1.2.3 <1.3.0 "reasonably close to 1.2.3"
^1.2.3 is >=1.2.3 <2.0.0 "compatible with 1.2.3"
^0.2.3 is >=0.2.3 <0.3.0 (0.x.x is special)
@ -3916,7 +3917,7 @@ versions (1.2.3-rc1) are not matched. >
* any version
x same
1.2.3 - 2.3.4 is >=1.2.3 <=2.3.4
1.2.3 - 2.3.4 is >=1.2.3 <2.3.4
Partial right: missing pieces treated as x (2.3 => 2.3.x).
1.2.3 - 2.3 is >=1.2.3 <2.4.0
@ -3927,6 +3928,43 @@ versions (1.2.3-rc1) are not matched. >
<
*vim.VersionRange*
Fields: ~
• {from} (`vim.Version`)
• {to}? (`vim.Version`)
• {has} (`fun(self: vim.VersionRange, version: string|vim.Version): boolean`)
See |VersionRange:has()|.
VersionRange:has({version}) *VersionRange:has()*
Check if a version is in the range (inclusive `from`, exclusive `to`).
Example: >lua
local r = vim.version.range('1.0.0 - 2.0.0')
print(r:has('1.9.9')) -- true
print(r:has('2.0.0')) -- false
print(r:has(vim.version())) -- check against current Nvim version
<
Or use cmp(), le(), lt(), ge(), gt(), and/or eq() to compare a version
against `.to` and `.from` directly: >lua
local r = vim.version.range('1.0.0 - 2.0.0') -- >=1.0, <2.0
print(vim.version.ge({1,0,3}, r.from) and vim.version.lt({1,0,3}, r.to))
<
Attributes: ~
Since: 0.9.0
Parameters: ~
• {version} (`string|vim.Version`)
Return: ~
(`boolean`)
See also: ~
• https://github.com/npm/node-semver#ranges
vim.version.cmp({v1}, {v2}) *vim.version.cmp()*
Parses and compares two version objects (the result of
|vim.version.parse()|, or specified literally as a `{major, minor, patch}`
@ -3997,6 +4035,21 @@ vim.version.gt({v1}, {v2}) *vim.version.gt()*
Return: ~
(`boolean`)
vim.version.intersect({r1}, {r2}) *vim.version.intersect()*
WARNING: This feature is experimental/unstable.
Computes the common range shared by the given ranges.
Parameters: ~
• {r1} (`vim.VersionRange`) First range to intersect. See
|vim.VersionRange|.
• {r2} (`vim.VersionRange`) Second range to intersect. See
|vim.VersionRange|.
Return: ~
(`vim.VersionRange?`) Maximal range that is present inside both `r1`
and `r2`. `nil` if such range does not exist. See |vim.VersionRange|.
vim.version.last({versions}) *vim.version.last()*
TODO: generalize this, move to func.lua
@ -4057,29 +4110,8 @@ vim.version.parse({version}, {opts}) *vim.version.parse()*
• https://semver.org/spec/v2.0.0.html
vim.version.range({spec}) *vim.version.range()*
Parses a semver |version-range| "spec" and returns a range object: >
{
from: Version
to: Version
has(v: string|Version)
}
<
`:has()` checks if a version is in the range (inclusive `from`, exclusive
`to`).
Example: >lua
local r = vim.version.range('1.0.0 - 2.0.0')
print(r:has('1.9.9')) -- true
print(r:has('2.0.0')) -- false
print(r:has(vim.version())) -- check against current Nvim version
<
Or use cmp(), le(), lt(), ge(), gt(), and/or eq() to compare a version
against `.to` and `.from` directly: >lua
local r = vim.version.range('1.0.0 - 2.0.0') -- >=1.0, <2.0
print(vim.version.ge({1,0,3}, r.from) and vim.version.lt({1,0,3}, r.to))
<
Parses a semver |version-range| "spec" and returns |vim.VersionRange|
object:
Attributes: ~
Since: 0.9.0
@ -4088,13 +4120,7 @@ vim.version.range({spec}) *vim.version.range()*
• {spec} (`string`) Version range "spec"
Return: ~
(`table?`) A table with the following fields:
• {from} (`vim.Version`)
• {to}? (`vim.Version`)
• {has} (`fun(self: vim.VersionRange, version: string|vim.Version)`)
See also: ~
• https://github.com/npm/node-semver#ranges
(`vim.VersionRange?`) See |vim.VersionRange|.
==============================================================================

View File

@ -199,6 +199,8 @@ LUA
• |vim.hl.range()| now allows multiple timed highlights.
• |vim.tbl_extend()| and |vim.tbl_deep_extend()| now accept a function behavior argument.
• |vim.fs.root()| can define "equal priority" via nested lists.
• |vim.version.range()| output can be converted to human-readable string with |tostring()|.
• |vim.version.intersect()| computes intersection of two version ranges.
OPTIONS

View File

@ -28,6 +28,7 @@
--- >1.2.3 greater than 1.2.3
--- <1.2.3 before 1.2.3
--- >=1.2.3 at least 1.2.3
--- <=1.2.3 at most 1.2.3
--- ~1.2.3 is >=1.2.3 <1.3.0 "reasonably close to 1.2.3"
--- ^1.2.3 is >=1.2.3 <2.0.0 "compatible with 1.2.3"
--- ^0.2.3 is >=0.2.3 <0.3.0 (0.x.x is special)
@ -42,7 +43,7 @@
--- * any version
--- x same
---
--- 1.2.3 - 2.3.4 is >=1.2.3 <=2.3.4
--- 1.2.3 - 2.3.4 is >=1.2.3 <2.3.4
---
--- Partial right: missing pieces treated as x (2.3 => 2.3.x).
--- 1.2.3 - 2.3 is >=1.2.3 <2.4.0
@ -222,40 +223,11 @@ function M.last(versions)
end
---@class vim.VersionRange
---@inlinedoc
---@field from vim.Version
---@field to? vim.Version
local VersionRange = {}
---@nodoc
---@param version string|vim.Version
function VersionRange:has(version)
if type(version) == 'string' then
---@diagnostic disable-next-line: cast-local-type
version = M.parse(version)
elseif getmetatable(version) ~= Version then
-- Need metatable to compare versions.
version = setmetatable(vim.deepcopy(version, true), Version)
end
if version then
if self.from == self.to then
return version == self.from
end
return version >= self.from and (self.to == nil or version < self.to)
end
end
--- Parses a semver |version-range| "spec" and returns a range object:
---
--- ```
--- {
--- from: Version
--- to: Version
--- has(v: string|Version)
--- }
--- ```
---
--- `:has()` checks if a version is in the range (inclusive `from`, exclusive `to`).
--- Check if a version is in the range (inclusive `from`, exclusive `to`).
---
--- Example:
---
@ -276,12 +248,42 @@ end
---
--- @see # https://github.com/npm/node-semver#ranges
--- @since 11
---
--- @param version string|vim.Version
--- @return boolean
function VersionRange:has(version)
if type(version) == 'string' then
---@diagnostic disable-next-line: cast-local-type
version = M.parse(version)
elseif getmetatable(version) ~= Version then
-- Need metatable to compare versions.
version = setmetatable(vim.deepcopy(version, true), Version)
end
if not version then
return false
end
if self.from == self.to then
return version == self.from
end
return version >= self.from and (self.to == nil or version < self.to)
end
local range_mt = {
__index = VersionRange,
__tostring = function(self)
if not self.to then
return '>=' .. tostring(self.from)
end
return ('%s - %s'):format(tostring(self.from), tostring(self.to))
end,
}
--- Parses a semver |version-range| "spec" and returns |vim.VersionRange| object:
--- @since 11
--- @param spec string Version range "spec"
--- @return vim.VersionRange?
function M.range(spec) -- Adapted from https://github.com/folke/lazy.nvim
if spec == '*' or spec == '' then
return setmetatable({ from = M.parse('0.0.0') }, { __index = VersionRange })
return setmetatable({ from = M.parse('0.0.0') }, range_mt)
end
---@type number?
@ -295,7 +297,7 @@ function M.range(spec) -- Adapted from https://github.com/folke/lazy.nvim
return setmetatable({
from = ra and ra.from,
to = rb and (#parts == 3 and rb.from or rb.to),
}, { __index = VersionRange })
}, range_mt)
end
---@type string, string
local mods, version = spec:lower():match('^([%^=<>~]*)(.*)$')
@ -314,9 +316,23 @@ function M.range(spec) -- Adapted from https://github.com/folke/lazy.nvim
from = M._version({})
elseif mods == '<=' then
from = M._version({})
to.patch = to.patch + 1
-- HACK: construct the smallest reasonable version bigger than `to`
-- to simulate `<=` while using exclusive right hand side
if to.prerelease then
to.prerelease = to.prerelease .. '.0'
else
to.patch = to.patch + 1
to.prerelease = '0'
end
elseif mods == '>' then
from.patch = from.patch + 1
-- HACK: construct the smallest reasonable version bigger than `from`
-- to simulate `>` while using inclusive left hand side
if from.prerelease then
from.prerelease = from.prerelease .. '.0'
else
from.patch = from.patch + 1
from.prerelease = '0'
end
to = nil
elseif mods == '>=' then
to = nil
@ -341,7 +357,25 @@ function M.range(spec) -- Adapted from https://github.com/folke/lazy.nvim
end
end
---@diagnostic enable: need-check-nil
return setmetatable({ from = from, to = to }, { __index = VersionRange })
return setmetatable({ from = from, to = to }, range_mt)
end
end
--- Computes the common range shared by the given ranges.
---
--- @since 14
--- @param r1 vim.VersionRange First range to intersect.
--- @param r2 vim.VersionRange Second range to intersect.
--- @return vim.VersionRange? Maximal range that is present inside both `r1` and `r2`.
--- `nil` if such range does not exist.
function M.intersect(r1, r2)
assert(getmetatable(r1) == range_mt)
assert(getmetatable(r2) == range_mt)
local from = r1.from <= r2.from and r2.from or r1.from
local to = (r1.to == nil or (r2.to ~= nil and r2.to <= r1.to)) and r2.to or r1.to
if to == nil or from < to or (from == to and r1:has(from) and r2:has(from)) then
return setmetatable({ from = from, to = to }, VersionRange)
end
end

View File

@ -58,10 +58,10 @@ describe('version', function()
['1.2.3'] = { from = { 1, 2, 3 }, to = { 1, 2, 3 } },
['1.2'] = { from = { 1, 2, 0 }, to = { 1, 3, 0 } },
['=1.2.3'] = { from = { 1, 2, 3 }, to = { 1, 2, 3 } },
['>1.2.3'] = { from = { 1, 2, 4 } },
['>1.2.3'] = { from = '1.2.4-0' },
['>=1.2.3'] = { from = { 1, 2, 3 } },
['<1.2.3'] = { from = { 0, 0, 0 }, to = { 1, 2, 3 } },
['<=1.2.3'] = { from = { 0, 0, 0 }, to = { 1, 2, 4 } },
['<=1.2.3'] = { from = { 0, 0, 0 }, to = '1.2.4-0' },
['~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 } },
@ -89,6 +89,11 @@ describe('version', function()
eq(output, range)
end)
it('tostring() ' .. input, function()
eq(type(tostring(range)), 'string')
eq(vim.version.range(tostring(range)), range)
end)
it('[from] in range ' .. input, function()
assert(range:has(output.from))
end)
@ -119,6 +124,20 @@ describe('version', function()
assert(not vim.version.range('1.2.3-alpha'):has('1.2.3-beta'))
assert(vim.version.range('>0.10'):has('0.12.0-dev'))
assert(not vim.version.range('>=0.12'):has('0.12.0-dev'))
assert(not vim.version.range('<=1.2.3'):has('1.2.4-alpha'))
assert(not vim.version.range('<=1.2.3-0'):has('1.2.3'))
assert(not vim.version.range('<=1.2.3-alpha'):has('1.2.3'))
assert(not vim.version.range('<=1.2.3-1'):has('1.2.4-0'))
assert(vim.version.range('<=1.2.3-0'):has('1.2.3-0'))
assert(vim.version.range('<=1.2.3-alpha'):has('1.2.3-alpha'))
assert(vim.version.range('>1.2.3'):has('1.2.4-0'))
assert(vim.version.range('>1.2.3'):has('1.2.4-alpha'))
assert(vim.version.range('>1.2.3-0'):has('1.2.3-1'))
local range_alpha = vim.version.range('1.2.3-alpha')
eq(vim.version.range(tostring(range_alpha)), range_alpha)
end)
it('returns nil with empty version', function()
@ -126,6 +145,131 @@ describe('version', function()
end)
end)
describe('intersect', function()
local check = function(input, output)
local r1 = vim.version.range(input[1])
local r2 = vim.version.range(input[2])
if output == nil then
eq(vim.version.intersect(r1, r2), nil)
eq(vim.version.intersect(r2, r1), nil)
else
local ref = vim.version.range(output)
eq(vim.version.intersect(r1, r2), ref)
eq(vim.version.intersect(r2, r1), ref)
end
end
it('returns biggest common range', function()
check({ '>=1.2.3', '>=2.0.0' }, '>=2.0.0')
check({ '>=1.2.3', '>=1.3.0' }, '>=1.3.0')
check({ '>=1.2.3', '>=1.2.4' }, '>=1.2.4')
check({ '>=1.2.3', '>=1.2.3' }, '>=1.2.3')
check({ '>=1.2.3', '>1.2.4' }, '>1.2.4')
check({ '>=1.2.3', '>1.2.3' }, '>1.2.3')
check({ '>=1.2.3', '>1.2.2' }, '>=1.2.3')
check({ '>1.2.3', '>1.2.4' }, '>1.2.4')
check({ '>1.2.3', '>1.2.3' }, '>1.2.3')
check({ '>=1.2.3', '1.2.0 - 1.2.2' }, nil)
check({ '>=1.2.3', '1.2.0 - 1.2.2' }, nil)
check({ '>=1.2.3', '1.2.0 - 1.2.3' }, nil)
check({ '>=1.2.3', '1.2.0 - 1.2.4' }, '1.2.3 - 1.2.4')
check({ '>=1.2.3', '1.2.3 - 1.2.4' }, '1.2.3 - 1.2.4')
check({ '>=1.2.3', '1.2.4 - 1.3.0' }, '1.2.4 - 1.3.0')
check({ '>1.2.3', '1.2.0 - 1.2.2' }, nil)
check({ '>1.2.3', '1.2.0 - 1.2.2' }, nil)
check({ '>1.2.3', '1.2.0 - 1.2.3' }, nil)
check({ '>1.2.3', '1.2.0 - 1.2.4' }, '1.2.4-0 - 1.2.4')
check({ '>1.2.3', '1.2.3 - 1.2.4' }, '1.2.4-0 - 1.2.4')
check({ '>1.2.3', '1.2.4 - 1.3.0' }, '1.2.4 - 1.3.0')
check({ '>=1.2.3', '=1.2.4' }, '=1.2.4')
check({ '>=1.2.3', '=1.2.3' }, '=1.2.3')
check({ '>=1.2.3', '=1.2.2' }, nil)
check({ '>1.2.3', '=1.2.4' }, '=1.2.4')
check({ '>1.2.3', '=1.2.3' }, nil)
check({ '>1.2.3', '=1.2.2' }, nil)
check({ '>=1.2.3', '<=1.3.0' }, '1.2.3 - 1.3.1-0')
check({ '>=1.2.3', '<1.3.0' }, '1.2.3 - 1.3.0')
check({ '>=1.2.3', '<=1.2.3' }, '1.2.3 - 1.2.4-0') -- A better result would be '=1.2.3'
check({ '>=1.2.3', '<1.2.3' }, nil)
check({ '>=1.2.3', '<=1.2.2' }, nil)
check({ '>=1.2.3', '<1.2.2' }, nil)
check({ '>1.2.3', '<=1.3.0' }, '1.2.4-0 - 1.3.1-0')
check({ '>1.2.3', '<1.3.0' }, '1.2.4-0 - 1.3.0')
check({ '>1.2.3', '<=1.2.3' }, nil)
check({ '>1.2.3', '<1.2.3' }, nil)
check({ '>1.2.3', '<=1.2.2' }, nil)
check({ '>1.2.3', '<1.2.2' }, nil)
check({ '1.2.3 - 1.3.0', '1.3.1 - 1.4.0' }, nil)
check({ '1.2.3 - 1.3.0', '1.3.0 - 1.4.0' }, nil)
check({ '1.2.3 - 1.3.0', '1.2.4 - 1.4.0' }, '1.2.4 - 1.3.0')
check({ '1.2.3 - 1.3.0', '1.2.3 - 1.4.0' }, '1.2.3 - 1.3.0')
check({ '1.2.3 - 1.3.0', '1.2.2 - 1.4.0' }, '1.2.3 - 1.3.0')
check({ '1.2.3 - 1.3.0', '1.2.4 - 1.3.0' }, '1.2.4 - 1.3.0')
check({ '1.2.3 - 1.3.0', '1.2.3 - 1.3.0' }, '1.2.3 - 1.3.0')
check({ '1.2.3 - 1.3.0', '=1.4.0' }, nil)
check({ '1.2.3 - 1.3.0', '=1.3.0' }, nil)
check({ '1.2.3 - 1.3.0', '=1.2.4' }, '=1.2.4')
check({ '1.2.3 - 1.3.0', '=1.2.3' }, '=1.2.3')
check({ '1.2.3 - 1.3.0', '=1.2.2' }, nil)
check({ '1.2.3 - 1.3.0', '<=1.4.0' }, '1.2.3 - 1.3.0')
check({ '1.2.3 - 1.3.0', '<1.4.0' }, '1.2.3 - 1.3.0')
check({ '1.2.3 - 1.3.0', '<=1.3.0' }, '1.2.3 - 1.3.0')
check({ '1.2.3 - 1.3.0', '<1.3.0' }, '1.2.3 - 1.3.0')
check({ '1.2.3 - 1.3.0', '<=1.2.4' }, '1.2.3 - 1.2.5-0')
check({ '1.2.3 - 1.3.0', '<1.2.5' }, '1.2.3 - 1.2.5')
check({ '1.2.3 - 1.3.0', '<=1.2.3' }, '1.2.3 - 1.2.4-0') -- A better result would be '=1.2.3'
check({ '1.2.3 - 1.3.0', '<1.2.3' }, nil)
check({ '1.2.3 - 1.3.0', '<=1.2.2' }, nil)
check({ '1.2.3 - 1.3.0', '<1.2.2' }, nil)
check({ '=1.2.3', '=1.2.4' }, nil)
check({ '=1.2.3', '=1.2.3' }, '=1.2.3')
check({ '=1.2.3', '<1.2.3' }, nil)
check({ '<=1.2.2', '=1.2.3' }, nil)
check({ '=1.2.3', '<=1.3.0' }, '=1.2.3')
check({ '=1.2.3', '<1.3.0' }, '=1.2.3')
check({ '=1.2.3', '<=1.2.3' }, '=1.2.3')
check({ '=1.2.3', '<1.2.3' }, nil)
check({ '=1.2.3', '<=1.2.2' }, nil)
check({ '=1.2.3', '<1.2.2' }, nil)
check({ '<=1.2.3', '<=1.3.0' }, '<=1.2.3')
check({ '<=1.2.3', '<1.3.0' }, '<=1.2.3')
check({ '<=1.2.3', '<=1.2.3' }, '<=1.2.3')
check({ '<=1.2.3', '<1.2.3' }, '<1.2.3')
check({ '<=1.2.3', '<=1.2.2' }, '<=1.2.2')
check({ '<=1.2.3', '<1.2.2' }, '<1.2.2')
check({ '<1.2.3', '<=1.3.0' }, '<1.2.3')
check({ '<1.2.3', '<1.3.0' }, '<1.2.3')
check({ '<1.2.3', '<=1.2.3' }, '<1.2.3')
check({ '<1.2.3', '<1.2.3' }, '<1.2.3')
check({ '<1.2.3', '<=1.2.2' }, '<=1.2.2')
check({ '<1.2.3', '<1.2.2' }, '<1.2.2')
-- Selective coverage of ranges with pre-releases
check({ '>=1.2.3-0', '>=1.2.3-1' }, '>=1.2.3-1')
check({ '>=1.2.3-alpha', '>=1.2.3-beta' }, '>=1.2.3-beta')
check({ '>=1.2.3-0', '>=1.2.3-alpha' }, '>=1.2.3-alpha')
check({ '>=1.2.3-0', '<1.2.3' }, '1.2.3-0 - 1.2.3')
check({ '>=1.2.3-0', '<1.2.3-1' }, '1.2.3-0 - 1.2.3-1')
check({ '>=1.2.3-alpha', '<1.2.3-beta' }, '1.2.3-alpha - 1.2.3-beta')
check({ '>=1.2.3-0', '1.2.2 - 1.2.3' }, '1.2.3-0 - 1.2.3')
check({ '>=1.2.3-0', '<=1.2.2' }, nil)
check({ '<=1.2.3-0', '>=1.2.3' }, nil)
check({ '<=1.2.3-0', '=1.2.3' }, nil)
check({ '>=1.2.3-0', '<1.2.3-2' }, '1.2.3-0 - 1.2.3-2')
end)
end)
describe('cmp()', function()
local testcases = {
{ v1 = 'v0.0.99', v2 = 'v9.0.0', want = -1 },