version.lua (15348B)
1 --- @brief 2 --- The `vim.version` module provides functions for comparing versions and ranges 3 --- conforming to the https://semver.org spec. Plugins, and plugin managers, can use this to check 4 --- available tools and dependencies on the current system. 5 --- 6 --- Example: 7 --- 8 --- ```lua 9 --- local v = vim.version.parse(vim.system({'tmux', '-V'}):wait().stdout, {strict=false}) 10 --- if vim.version.gt(v, {3, 2, 0}) then 11 --- -- ... 12 --- end 13 --- ``` 14 --- 15 --- [vim.version()]() returns the version of the current Nvim process. 16 --- 17 --- VERSION RANGE SPEC [version-range]() 18 --- 19 --- A version "range spec" defines a semantic version range which can be tested against a version, 20 --- using |vim.version.range()|. 21 --- 22 --- Supported range specs are shown in the following table. 23 --- Note: suffixed versions (1.2.3-rc1) are not matched. 24 --- 25 --- ``` 26 --- 1.2.3 is 1.2.3 27 --- =1.2.3 is 1.2.3 28 --- >1.2.3 greater than 1.2.3 29 --- <1.2.3 before 1.2.3 30 --- >=1.2.3 at least 1.2.3 31 --- <=1.2.3 at most 1.2.3 32 --- ~1.2.3 is >=1.2.3 <1.3.0 "reasonably close to 1.2.3" 33 --- ^1.2.3 is >=1.2.3 <2.0.0 "compatible with 1.2.3" 34 --- ^0.2.3 is >=0.2.3 <0.3.0 (0.x.x is special) 35 --- ^0.0.1 is =0.0.1 (0.0.x is special) 36 --- ^1.2 is >=1.2.0 <2.0.0 (like ^1.2.0) 37 --- ~1.2 is >=1.2.0 <1.3.0 (like ~1.2.0) 38 --- ^1 is >=1.0.0 <2.0.0 "compatible with 1" 39 --- ~1 same "reasonably close to 1" 40 --- 1.x same 41 --- 1.* same 42 --- 1 same 43 --- * any version 44 --- x same 45 --- 46 --- 1.2.3 - 2.3.4 is >=1.2.3 <2.3.4 47 --- 48 --- Partial right: missing pieces treated as x (2.3 => 2.3.x). 49 --- 1.2.3 - 2.3 is >=1.2.3 <2.4.0 50 --- 1.2.3 - 2 is >=1.2.3 <3.0.0 51 --- 52 --- Partial left: missing pieces treated as 0 (1.2 => 1.2.0). 53 --- 1.2 - 2.3.0 is 1.2.0 - 2.3.0 54 --- ``` 55 56 local M = {} 57 58 ---@nodoc 59 ---@class vim.Version 60 ---@field [1] number 61 ---@field [2] number 62 ---@field [3] number 63 ---@field major number 64 ---@field minor number 65 ---@field patch number 66 ---@field prerelease? string 67 ---@field build? string 68 local Version = {} 69 Version.__index = Version 70 71 --- Compares prerelease strings: per semver, number parts must be must be treated as numbers: 72 --- "pre1.10" is greater than "pre1.2". https://semver.org/#spec-item-11 73 ---@param prerel1 string? 74 ---@param prerel2 string? 75 local function cmp_prerel(prerel1, prerel2) 76 if not prerel1 or not prerel2 then 77 return prerel1 and -1 or (prerel2 and 1 or 0) 78 end 79 -- TODO(justinmk): not fully spec-compliant; this treats non-dot-delimited digit sequences as 80 -- numbers. Maybe better: "(.-)(%.%d*)". 81 local iter1 = prerel1:gmatch('([^0-9]*)(%d*)') 82 local iter2 = prerel2:gmatch('([^0-9]*)(%d*)') 83 while true do 84 local word1, n1 = iter1() --- @type string?, string|number|nil 85 local word2, n2 = iter2() --- @type string?, string|number|nil 86 if word1 == nil and word2 == nil then -- Done iterating. 87 return 0 88 end 89 word1, n1, word2, n2 = 90 word1 or '', n1 and tonumber(n1) or 0, word2 or '', n2 and tonumber(n2) or 0 91 if word1 ~= word2 then 92 return word1 < word2 and -1 or 1 93 end 94 if n1 ~= n2 then 95 return n1 < n2 and -1 or 1 96 end 97 end 98 end 99 100 function Version:__index(key) 101 return type(key) == 'number' and ({ self.major, self.minor, self.patch })[key] or Version[key] 102 end 103 104 function Version:__newindex(key, value) 105 if key == 1 then 106 self.major = value 107 elseif key == 2 then 108 self.minor = value 109 elseif key == 3 then 110 self.patch = value 111 else 112 rawset(self, key, value) 113 end 114 end 115 116 ---@param other vim.Version 117 function Version:__eq(other) 118 for i = 1, 3 do 119 if self[i] ~= other[i] then 120 return false 121 end 122 end 123 return 0 == cmp_prerel(self.prerelease, other.prerelease) 124 end 125 126 function Version:__tostring() 127 local ret = table.concat({ self.major, self.minor, self.patch }, '.') 128 if self.prerelease then 129 ret = ret .. '-' .. self.prerelease 130 end 131 if self.build and self.build ~= vim.NIL then 132 ret = ret .. '+' .. self.build 133 end 134 return ret 135 end 136 137 ---@param other vim.Version 138 function Version:__lt(other) 139 for i = 1, 3 do 140 if self[i] > other[i] then 141 return false 142 elseif self[i] < other[i] then 143 return true 144 end 145 end 146 return -1 == cmp_prerel(self.prerelease, other.prerelease) 147 end 148 149 ---@param other vim.Version 150 function Version:__le(other) 151 return self < other or self == other 152 end 153 154 --- @private 155 --- 156 --- Creates a new Version object, or returns `nil` if `version` is invalid. 157 --- 158 --- @param version string|number[]|vim.Version 159 --- @param strict? boolean Reject "1.0", "0-x", "3.2a" or other non-conforming version strings 160 --- @return vim.Version? 161 function M._version(version, strict) -- Adapted from https://github.com/folke/lazy.nvim 162 if type(version) == 'table' then 163 if version.major then 164 return setmetatable(vim.deepcopy(version, true), Version) 165 end 166 return setmetatable({ 167 major = version[1] or 0, 168 minor = version[2] or 0, 169 patch = version[3] or 0, 170 }, Version) 171 end 172 173 if not strict then -- TODO: add more "scrubbing". 174 --- @cast version string 175 version = version:match('%d[^ ]*') 176 end 177 178 if version == nil then 179 return nil 180 end 181 182 local prerel = version:match('%-([^+]*)') 183 local prerel_strict = version:match('%-([0-9A-Za-z-]*)') 184 if 185 strict 186 and prerel 187 and (prerel_strict == nil or prerel_strict == '' or not vim.startswith(prerel, prerel_strict)) 188 then 189 return nil -- Invalid prerelease. 190 end 191 local build = prerel and version:match('%-[^+]*%+(.*)$') or version:match('%+(.*)$') 192 local major, minor, patch = 193 version:match('^v?(%d+)%.?(%d*)%.?(%d*)' .. (strict and (prerel and '%-' or '$') or '')) 194 195 if 196 (not strict and major) 197 or (major and minor and patch and major ~= '' and minor ~= '' and patch ~= '') 198 then 199 return setmetatable({ 200 major = tonumber(major), 201 minor = minor == '' and 0 or tonumber(minor), 202 patch = patch == '' and 0 or tonumber(patch), 203 prerelease = prerel ~= '' and prerel or nil, 204 build = build ~= '' and build or nil, 205 }, Version) 206 end 207 return nil -- Invalid version string. 208 end 209 210 ---TODO: generalize this, move to func.lua 211 --- 212 ---@generic T: vim.Version 213 ---@param versions T[] 214 ---@return T? 215 function M.last(versions) 216 local last = versions[1] 217 for i = 2, #versions do 218 if versions[i] > last then 219 last = versions[i] 220 end 221 end 222 return last 223 end 224 225 ---@class vim.VersionRange 226 ---@field from vim.Version 227 ---@field to? vim.Version 228 local VersionRange = {} 229 230 --- Check if a version is in the range (inclusive `from`, exclusive `to`). 231 --- 232 --- Example: 233 --- 234 --- ```lua 235 --- local r = vim.version.range('1.0.0 - 2.0.0') 236 --- print(r:has('1.9.9')) -- true 237 --- print(r:has('2.0.0')) -- false 238 --- print(r:has(vim.version())) -- check against current Nvim version 239 --- ``` 240 --- 241 --- Or use cmp(), le(), lt(), ge(), gt(), and/or eq() to compare a version 242 --- against `.to` and `.from` directly: 243 --- 244 --- ```lua 245 --- local r = vim.version.range('1.0.0 - 2.0.0') -- >=1.0, <2.0 246 --- print(vim.version.ge({1,0,3}, r.from) and vim.version.lt({1,0,3}, r.to)) 247 --- ``` 248 --- 249 --- @see # https://github.com/npm/node-semver#ranges 250 --- @since 11 251 --- @param version string|vim.Version 252 --- @return boolean 253 function VersionRange:has(version) 254 if type(version) == 'string' then 255 ---@diagnostic disable-next-line: cast-local-type 256 version = M.parse(version) 257 elseif getmetatable(version) ~= Version then 258 -- Need metatable to compare versions. 259 version = setmetatable(vim.deepcopy(version, true), Version) 260 end 261 if not version then 262 return false 263 end 264 if self.from == self.to then 265 return version == self.from 266 end 267 return version >= self.from and (self.to == nil or version < self.to) 268 end 269 270 local range_mt = { 271 __index = VersionRange, 272 __tostring = function(self) 273 if not self.to then 274 return '>=' .. tostring(self.from) 275 end 276 return ('%s - %s'):format(tostring(self.from), tostring(self.to)) 277 end, 278 } 279 280 --- Parses a semver |version-range| "spec" and returns |vim.VersionRange| object: 281 --- @since 11 282 --- @param spec string Version range "spec" 283 --- @return vim.VersionRange? 284 function M.range(spec) -- Adapted from https://github.com/folke/lazy.nvim 285 if spec == '*' or spec == '' then 286 return setmetatable({ from = M.parse('0.0.0') }, range_mt) 287 end 288 289 ---@type number? 290 local hyphen = spec:find(' - ', 1, true) 291 if hyphen then 292 local a = spec:sub(1, hyphen - 1) 293 local b = spec:sub(hyphen + 3) 294 local parts = vim.split(b, '.', { plain = true }) 295 local ra = M.range(a) 296 local rb = M.range(b) 297 return setmetatable({ 298 from = ra and ra.from, 299 to = rb and (#parts == 3 and rb.from or rb.to), 300 }, range_mt) 301 end 302 ---@type string, string 303 local mods, version = spec:lower():match('^([%^=<>~]*)(.*)$') 304 version = version:gsub('%.[%*x]', '') 305 local parts = vim.split(version:gsub('%-.*', ''), '.', { plain = true }) 306 if #parts < 3 and mods == '' then 307 mods = '~' 308 end 309 310 local semver = M.parse(version) 311 if semver then 312 local from = semver --- @type vim.Version? 313 local to = vim.deepcopy(semver, true) --- @type vim.Version? 314 ---@diagnostic disable: need-check-nil 315 if mods == '<' then 316 from = M._version({}) 317 elseif mods == '<=' then 318 from = M._version({}) 319 -- HACK: construct the smallest reasonable version bigger than `to` 320 -- to simulate `<=` while using exclusive right hand side 321 if to.prerelease then 322 to.prerelease = to.prerelease .. '.0' 323 else 324 to.patch = to.patch + 1 325 to.prerelease = '0' 326 end 327 elseif mods == '>' then 328 -- HACK: construct the smallest reasonable version bigger than `from` 329 -- to simulate `>` while using inclusive left hand side 330 if from.prerelease then 331 from.prerelease = from.prerelease .. '.0' 332 else 333 from.patch = from.patch + 1 334 from.prerelease = '0' 335 end 336 to = nil 337 elseif mods == '>=' then 338 to = nil 339 elseif mods == '~' then 340 if #parts >= 2 then 341 to[2] = to[2] + 1 342 to[3] = 0 343 else 344 to[1] = to[1] + 1 345 to[2] = 0 346 to[3] = 0 347 end 348 elseif mods == '^' then 349 for i = 1, 3 do 350 if to[i] ~= 0 then 351 to[i] = to[i] + 1 352 for j = i + 1, 3 do 353 to[j] = 0 354 end 355 break 356 end 357 end 358 end 359 ---@diagnostic enable: need-check-nil 360 return setmetatable({ from = from, to = to }, range_mt) 361 end 362 end 363 364 --- Computes the common range shared by the given ranges. 365 --- 366 --- @since 14 367 --- @param r1 vim.VersionRange First range to intersect. 368 --- @param r2 vim.VersionRange Second range to intersect. 369 --- @return vim.VersionRange? Maximal range that is present inside both `r1` and `r2`. 370 --- `nil` if such range does not exist. 371 function M.intersect(r1, r2) 372 assert(getmetatable(r1) == range_mt) 373 assert(getmetatable(r2) == range_mt) 374 375 local from = r1.from <= r2.from and r2.from or r1.from 376 local to = (r1.to == nil or (r2.to ~= nil and r2.to <= r1.to)) and r2.to or r1.to 377 if to == nil or from < to or (from == to and r1:has(from) and r2:has(from)) then 378 return setmetatable({ from = from, to = to }, VersionRange) 379 end 380 end 381 382 ---@param v string|vim.Version 383 ---@return string 384 local function create_err_msg(v) 385 if type(v) == 'string' then 386 return string.format('invalid version: "%s"', tostring(v)) 387 elseif type(v) == 'table' and v.major then 388 return string.format('invalid version: %s', vim.inspect(v)) 389 end 390 return string.format('invalid version: %s (%s)', tostring(v), type(v)) 391 end 392 393 --- Parses and compares two version objects (the result of |vim.version.parse()|, or 394 --- specified literally as a `{major, minor, patch}` tuple, e.g. `{1, 0, 3}`). 395 --- 396 --- Example: 397 --- 398 --- ```lua 399 --- if vim.version.cmp({1,0,3}, {0,2,1}) == 0 then 400 --- -- ... 401 --- end 402 --- local v1 = vim.version.parse('1.0.3-pre') 403 --- local v2 = vim.version.parse('0.2.1') 404 --- if vim.version.cmp(v1, v2) == 0 then 405 --- -- ... 406 --- end 407 --- ``` 408 --- 409 --- @note Per semver, build metadata is ignored when comparing two otherwise-equivalent versions. 410 --- @since 11 411 --- 412 ---@param v1 vim.Version|number[]|string Version object. 413 ---@param v2 vim.Version|number[]|string Version to compare with `v1`. 414 ---@return integer -1 if `v1 < v2`, 0 if `v1 == v2`, 1 if `v1 > v2`. 415 function M.cmp(v1, v2) 416 local v1_parsed = assert(M._version(v1), create_err_msg(v1)) 417 local v2_parsed = assert(M._version(v2), create_err_msg(v1)) 418 if v1_parsed == v2_parsed then 419 return 0 420 end 421 if v1_parsed > v2_parsed then 422 return 1 423 end 424 return -1 425 end 426 427 ---Returns `true` if the given versions are equal. See |vim.version.cmp()| for usage. 428 ---@since 11 429 ---@param v1 vim.Version|number[]|string 430 ---@param v2 vim.Version|number[]|string 431 ---@return boolean 432 function M.eq(v1, v2) 433 return M.cmp(v1, v2) == 0 434 end 435 436 ---Returns `true` if `v1 <= v2`. See |vim.version.cmp()| for usage. 437 ---@since 12 438 ---@param v1 vim.Version|number[]|string 439 ---@param v2 vim.Version|number[]|string 440 ---@return boolean 441 function M.le(v1, v2) 442 return M.cmp(v1, v2) <= 0 443 end 444 445 ---Returns `true` if `v1 < v2`. See |vim.version.cmp()| for usage. 446 ---@since 11 447 ---@param v1 vim.Version|number[]|string 448 ---@param v2 vim.Version|number[]|string 449 ---@return boolean 450 function M.lt(v1, v2) 451 return M.cmp(v1, v2) == -1 452 end 453 454 ---Returns `true` if `v1 >= v2`. See |vim.version.cmp()| for usage. 455 ---@since 12 456 ---@param v1 vim.Version|number[]|string 457 ---@param v2 vim.Version|number[]|string 458 ---@return boolean 459 function M.ge(v1, v2) 460 return M.cmp(v1, v2) >= 0 461 end 462 463 ---Returns `true` if `v1 > v2`. See |vim.version.cmp()| for usage. 464 ---@since 11 465 ---@param v1 vim.Version|number[]|string 466 ---@param v2 vim.Version|number[]|string 467 ---@return boolean 468 function M.gt(v1, v2) 469 return M.cmp(v1, v2) == 1 470 end 471 472 ---@class vim.version.parse.Opts 473 ---@inlinedoc 474 --- 475 --- If `true`, no coercion is attempted on input not conforming to semver v2.0.0. 476 --- If `false`, `parse()` attempts to coerce input such as "1.0", "0-x", "tmux 3.2a" into valid 477 --- versions. 478 --- (default: `false`) 479 ---@field strict? boolean 480 481 --- Parses a semantic version string and returns a version object which can be used with other 482 --- `vim.version` functions. For example "1.0.1-rc1+build.2" returns: 483 --- 484 --- ``` 485 --- { major = 1, minor = 0, patch = 1, prerelease = "rc1", build = "build.2" } 486 --- ``` 487 --- 488 ---@see # https://semver.org/spec/v2.0.0.html 489 ---@since 11 490 --- 491 ---@param version string Version string to parse. 492 ---@param opts vim.version.parse.Opts? Options for parsing. 493 ---@return vim.Version? # `Version` object or `nil` if input is invalid. 494 function M.parse(version, opts) 495 assert(type(version) == 'string', create_err_msg(version)) 496 opts = opts or { strict = false } 497 return M._version(version, opts.strict) 498 end 499 500 setmetatable(M, { 501 --- Returns the current Nvim version. 502 ---@return vim.Version 503 __call = function() 504 local version = vim.fn.api_info().version ---@type vim.Version 505 -- Workaround: vim.fn.api_info().version reports "prerelease" as a boolean. 506 version.prerelease = version.prerelease and 'dev' or nil 507 return setmetatable(version, Version) 508 end, 509 }) 510 511 return M