neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

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