commit f731766474901e5e345e0ca630315ef69122e556
parent 99873296beb4a868d7a2ac63d80bcaa206ff1d36
Author: Justin M. Keyes <justinkz@gmail.com>
Date: Tue, 1 Jul 2025 04:19:42 -0700
Merge #34715 vim.version improvements
Diffstat:
4 files changed, 276 insertions(+), 70 deletions(-)
diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt
@@ -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|.
==============================================================================
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
@@ -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
diff --git a/runtime/lua/vim/version.lua b/runtime/lua/vim/version.lua
@@ -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
diff --git a/test/functional/lua/version_spec.lua b/test/functional/lua/version_spec.lua
@@ -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 },