diff options
Diffstat (limited to 'runtime/lua/vim')
-rw-r--r-- | runtime/lua/vim/_init_packages.lua | 5 | ||||
-rw-r--r-- | runtime/lua/vim/_inspector.lua | 97 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/semantic_tokens.lua | 183 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter.lua | 26 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/_range.lua | 20 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/languagetree.lua | 34 | ||||
-rw-r--r-- | runtime/lua/vim/version.lua | 277 |
7 files changed, 520 insertions, 122 deletions
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/_inspector.lua b/runtime/lua/vim/_inspector.lua index 9e91597192..92d380b08c 100644 --- a/runtime/lua/vim/_inspector.lua +++ b/runtime/lua/vim/_inspector.lua @@ -2,7 +2,7 @@ ---@field syntax boolean include syntax based highlight groups (defaults to true) ---@field treesitter boolean include treesitter based highlight groups (defaults to true) ---@field extmarks boolean|"all" include extmarks. When `all`, then extmarks without a `hl_group` will also be included (defaults to true) ----@field semantic_tokens boolean include semantic tokens (defaults to true) +---@field semantic_tokens boolean include semantic token highlights (defaults to true) local defaults = { syntax = true, treesitter = true, @@ -81,47 +81,54 @@ function vim.inspect_pos(bufnr, row, col, filter) end end - -- semantic tokens - if filter.semantic_tokens then - for _, token in ipairs(vim.lsp.semantic_tokens.get_at_pos(bufnr, row, col) or {}) do - token.hl_groups = { - type = resolve_hl({ hl_group = '@' .. token.type }), - modifiers = vim.tbl_map(function(modifier) - return resolve_hl({ hl_group = '@' .. modifier }) - end, token.modifiers or {}), - } - table.insert(results.semantic_tokens, token) + --- Convert an extmark tuple into a map-like table + --- @private + local function to_map(extmark) + extmark = { + id = extmark[1], + row = extmark[2], + col = extmark[3], + opts = resolve_hl(extmark[4]), + } + extmark.end_row = extmark.opts.end_row or extmark.row -- inclusive + extmark.end_col = extmark.opts.end_col or (extmark.col + 1) -- exclusive + return extmark + end + + --- Check if an extmark overlaps this position + --- @private + local function is_here(extmark) + return (row >= extmark.row and row <= extmark.end_row) -- within the rows of the extmark + and (row > extmark.row or col >= extmark.col) -- either not the first row, or in range of the col + and (row < extmark.end_row or col < extmark.end_col) -- either not in the last row or in range of the col + end + + -- all extmarks at this position + local extmarks = {} + for ns, nsid in pairs(vim.api.nvim_get_namespaces()) do + local ns_marks = vim.api.nvim_buf_get_extmarks(bufnr, nsid, 0, -1, { details = true }) + ns_marks = vim.tbl_map(to_map, ns_marks) + ns_marks = vim.tbl_filter(is_here, ns_marks) + for _, mark in ipairs(ns_marks) do + mark.ns_id = nsid + mark.ns = ns end + vim.list_extend(extmarks, ns_marks) + end + + if filter.semantic_tokens then + results.semantic_tokens = vim.tbl_filter(function(extmark) + return extmark.ns:find('vim_lsp_semantic_tokens') == 1 + end, extmarks) end - -- extmarks if filter.extmarks then - for ns, nsid in pairs(vim.api.nvim_get_namespaces()) do - if ns:find('vim_lsp_semantic_tokens') ~= 1 then - local extmarks = vim.api.nvim_buf_get_extmarks(bufnr, nsid, 0, -1, { details = true }) - for _, extmark in ipairs(extmarks) do - extmark = { - ns_id = nsid, - ns = ns, - id = extmark[1], - row = extmark[2], - col = extmark[3], - opts = resolve_hl(extmark[4]), - } - local end_row = extmark.opts.end_row or extmark.row -- inclusive - local end_col = extmark.opts.end_col or (extmark.col + 1) -- exclusive - if - (filter.extmarks == 'all' or extmark.opts.hl_group) -- filter hl_group - and (row >= extmark.row and row <= end_row) -- within the rows of the extmark - and (row > extmark.row or col >= extmark.col) -- either not the first row, or in range of the col - and (row < end_row or col < end_col) -- either not in the last row or in range of the col - then - table.insert(results.extmarks, extmark) - end - end - end - end + results.extmarks = vim.tbl_filter(function(extmark) + return extmark.ns:find('vim_lsp_semantic_tokens') ~= 1 + and (filter.extmarks == 'all' or extmark.opts.hl_group) + end, extmarks) end + return results end @@ -174,16 +181,17 @@ function vim.show_pos(bufnr, row, col, filter) nl() end + -- semantic tokens if #items.semantic_tokens > 0 then append('Semantic Tokens', 'Title') nl() - for _, token in ipairs(items.semantic_tokens) do - local client = vim.lsp.get_client_by_id(token.client_id) - client = client and (' (' .. client.name .. ')') or '' - item(token.hl_groups.type, 'type' .. client) - for _, modifier in ipairs(token.hl_groups.modifiers) do - item(modifier, 'modifier' .. client) - end + local sorted_marks = vim.fn.sort(items.semantic_tokens, function(left, right) + local left_first = left.opts.priority < right.opts.priority + or left.opts.priority == right.opts.priority and left.opts.hl_group < right.opts.hl_group + return left_first and -1 or 1 + end) + for _, extmark in ipairs(sorted_marks) do + item(extmark.opts, 'priority: ' .. extmark.opts.priority) end nl() end @@ -197,6 +205,7 @@ function vim.show_pos(bufnr, row, col, filter) end nl() end + -- extmarks if #items.extmarks > 0 then append('Extmarks', 'Title') diff --git a/runtime/lua/vim/lsp/semantic_tokens.lua b/runtime/lua/vim/lsp/semantic_tokens.lua index 24b5c6c24e..7983d066b8 100644 --- a/runtime/lua/vim/lsp/semantic_tokens.lua +++ b/runtime/lua/vim/lsp/semantic_tokens.lua @@ -8,8 +8,8 @@ local bit = require('bit') --- @field start_col number start column 0-based --- @field end_col number end column 0-based --- @field type string token type as string ---- @field modifiers string[] token modifiers as strings ---- @field extmark_added boolean whether this extmark has been added to the buffer yet +--- @field modifiers table token modifiers as a set. E.g., { static = true, readonly = true } +--- @field marked boolean whether this token has had extmarks applied --- --- @class STCurrentResult --- @field version number document version associated with this result @@ -36,10 +36,13 @@ local bit = require('bit') ---@field client_state table<number, STClientState> local STHighlighter = { active = {} } +--- Do a binary search of the tokens in the half-open range [lo, hi). +--- +--- Return the index i in range such that tokens[j].line < line for all j < i, and +--- tokens[j].line >= line for all j >= i, or return hi if no such index is found. +--- ---@private -local function binary_search(tokens, line) - local lo = 1 - local hi = #tokens +local function lower_bound(tokens, line, lo, hi) while lo < hi do local mid = math.floor((lo + hi) / 2) if tokens[mid].line < line then @@ -51,16 +54,34 @@ local function binary_search(tokens, line) return lo end +--- Do a binary search of the tokens in the half-open range [lo, hi). +--- +--- Return the index i in range such that tokens[j].line <= line for all j < i, and +--- tokens[j].line > line for all j >= i, or return hi if no such index is found. +--- +---@private +local function upper_bound(tokens, line, lo, hi) + while lo < hi do + local mid = math.floor((lo + hi) / 2) + if line < tokens[mid].line then + hi = mid + else + lo = mid + 1 + end + end + return lo +end + --- Extracts modifier strings from the encoded number in the token array --- ---@private ----@return string[] +---@return table<string, boolean> local function modifiers_from_number(x, modifiers_table) local modifiers = {} local idx = 1 while x > 0 do if bit.band(x, 1) == 1 then - modifiers[#modifiers + 1] = modifiers_table[idx] + modifiers[modifiers_table[idx]] = true end x = bit.rshift(x, 1) idx = idx + 1 @@ -109,7 +130,7 @@ local function tokens_to_ranges(data, bufnr, client) end_col = end_col, type = token_type, modifiers = modifiers, - extmark_added = false, + marked = false, } end end @@ -355,7 +376,7 @@ end --- ---@private function STHighlighter:on_win(topline, botline) - for _, state in pairs(self.client_state) do + for client_id, state in pairs(self.client_state) do local current_result = state.current_result if current_result.version and current_result.version == util.buf_versions[self.bufnr] then if not current_result.namespace_cleared then @@ -372,52 +393,55 @@ function STHighlighter:on_win(topline, botline) -- -- Instead, we have to use normal extmarks that can attach to locations -- in the buffer and are persisted between redraws. + -- + -- `strict = false` is necessary here for the 1% of cases where the + -- current result doesn't actually match the buffer contents. Some + -- LSP servers can respond with stale tokens on requests if they are + -- still processing changes from a didChange notification. + -- + -- LSP servers that do this _should_ follow up known stale responses + -- with a refresh notification once they've finished processing the + -- didChange notification, which would re-synchronize the tokens from + -- our end. + -- + -- The server I know of that does this is clangd when the preamble of + -- a file changes and the token request is processed with a stale + -- preamble while the new one is still being built. Once the preamble + -- finishes, clangd sends a refresh request which lets the client + -- re-synchronize the tokens. + + local set_mark = function(token, hl_group, delta) + vim.api.nvim_buf_set_extmark(self.bufnr, state.namespace, token.line, token.start_col, { + hl_group = hl_group, + end_col = token.end_col, + priority = vim.highlight.priorities.semantic_tokens + delta, + strict = false, + }) + end + + local ft = vim.bo[self.bufnr].filetype local highlights = current_result.highlights - local idx = binary_search(highlights, topline) + local first = lower_bound(highlights, topline, 1, #highlights + 1) + local last = upper_bound(highlights, botline, first, #highlights + 1) - 1 - for i = idx, #highlights do + for i = first, last do local token = highlights[i] - - if token.line > botline then - break - end - - if not token.extmark_added then - -- `strict = false` is necessary here for the 1% of cases where the - -- current result doesn't actually match the buffer contents. Some - -- LSP servers can respond with stale tokens on requests if they are - -- still processing changes from a didChange notification. - -- - -- LSP servers that do this _should_ follow up known stale responses - -- with a refresh notification once they've finished processing the - -- didChange notification, which would re-synchronize the tokens from - -- our end. - -- - -- The server I know of that does this is clangd when the preamble of - -- a file changes and the token request is processed with a stale - -- preamble while the new one is still being built. Once the preamble - -- finishes, clangd sends a refresh request which lets the client - -- re-synchronize the tokens. - api.nvim_buf_set_extmark(self.bufnr, state.namespace, token.line, token.start_col, { - hl_group = '@' .. token.type, - end_col = token.end_col, - priority = vim.highlight.priorities.semantic_tokens, - strict = false, - }) - - -- TODO(bfredl) use single extmark when hl_group supports table - if #token.modifiers > 0 then - for _, modifier in pairs(token.modifiers) do - api.nvim_buf_set_extmark(self.bufnr, state.namespace, token.line, token.start_col, { - hl_group = '@' .. modifier, - end_col = token.end_col, - priority = vim.highlight.priorities.semantic_tokens + 1, - strict = false, - }) - end + if not token.marked then + set_mark(token, string.format('@lsp.type.%s.%s', token.type, ft), 0) + for modifier, _ in pairs(token.modifiers) do + set_mark(token, string.format('@lsp.mod.%s.%s', modifier, ft), 1) + set_mark(token, string.format('@lsp.typemod.%s.%s.%s', token.type, modifier, ft), 2) end - - token.extmark_added = true + token.marked = true + + api.nvim_exec_autocmds('LspTokenUpdate', { + pattern = vim.api.nvim_buf_get_name(self.bufnr), + modeline = false, + data = { + token = token, + client_id = client_id, + }, + }) end end end @@ -588,7 +612,13 @@ end ---@param row number|nil Position row (default cursor position) ---@param col number|nil Position column (default cursor position) --- ----@return table|nil (table|nil) List of tokens at position +---@return table|nil (table|nil) List of tokens at position. Each token has +--- the following fields: +--- - line (number) line number, 0-based +--- - start_col (number) start column, 0-based +--- - end_col (number) end column, 0-based +--- - type (string) token type as string, e.g. "variable" +--- - modifiers (table) token modifiers as a set. E.g., { static = true, readonly = true } function M.get_at_pos(bufnr, row, col) if bufnr == nil or bufnr == 0 then bufnr = api.nvim_get_current_buf() @@ -608,7 +638,7 @@ function M.get_at_pos(bufnr, row, col) for client_id, client in pairs(highlighter.client_state) do local highlights = client.current_result.highlights if highlights then - local idx = binary_search(highlights, row) + local idx = lower_bound(highlights, row, 1, #highlights + 1) for i = idx, #highlights do local token = highlights[i] @@ -631,23 +661,60 @@ end --- Only has an effect if the buffer is currently active for semantic token --- highlighting (|vim.lsp.semantic_tokens.start()| has been called for it) --- ----@param bufnr (nil|number) default: current buffer +---@param bufnr (number|nil) filter by buffer. All buffers if nil, current +--- buffer if 0 function M.force_refresh(bufnr) vim.validate({ bufnr = { bufnr, 'n', true }, }) - if bufnr == nil or bufnr == 0 then - bufnr = api.nvim_get_current_buf() + local buffers = bufnr == nil and vim.tbl_keys(STHighlighter.active) + or bufnr == 0 and { api.nvim_get_current_buf() } + or { bufnr } + + for _, buffer in ipairs(buffers) do + local highlighter = STHighlighter.active[buffer] + if highlighter then + highlighter:reset() + highlighter:send_request() + end end +end +--- Highlight a semantic token. +--- +--- Apply an extmark with a given highlight group for a semantic token. The +--- mark will be deleted by the semantic token engine when appropriate; for +--- example, when the LSP sends updated tokens. This function is intended for +--- use inside |LspTokenUpdate| callbacks. +---@param token (table) a semantic token, found as `args.data.token` in +--- |LspTokenUpdate|. +---@param bufnr (number) the buffer to highlight +---@param client_id (number) The ID of the |vim.lsp.client| +---@param hl_group (string) Highlight group name +---@param opts (table|nil) Optional parameters. +--- - priority: (number|nil) Priority for the applied extmark. Defaults +--- to `vim.highlight.priorities.semantic_tokens + 3` +function M.highlight_token(token, bufnr, client_id, hl_group, opts) local highlighter = STHighlighter.active[bufnr] if not highlighter then return end - highlighter:reset() - highlighter:send_request() + local state = highlighter.client_state[client_id] + if not state then + return + end + + opts = opts or {} + local priority = opts.priority or vim.highlight.priorities.semantic_tokens + 3 + + vim.api.nvim_buf_set_extmark(bufnr, state.namespace, token.line, token.start_col, { + hl_group = hl_group, + end_col = token.end_col, + priority = priority, + strict = false, + }) end --- |lsp-handler| for the method `workspace/semanticTokens/refresh` diff --git a/runtime/lua/vim/treesitter.lua b/runtime/lua/vim/treesitter.lua index ee66ba9f9b..d13824076e 100644 --- a/runtime/lua/vim/treesitter.lua +++ b/runtime/lua/vim/treesitter.lua @@ -2,6 +2,7 @@ local a = vim.api local query = require('vim.treesitter.query') local language = require('vim.treesitter.language') local LanguageTree = require('vim.treesitter.languagetree') +local Range = require('vim.treesitter._range') ---@type table<integer,LanguageTree> local parsers = setmetatable({}, { __mode = 'v' }) @@ -190,20 +191,7 @@ end --- ---@return boolean True if the position is in node range function M.is_in_node_range(node, line, col) - local start_line, start_col, end_line, end_col = M.get_node_range(node) - if line >= start_line and line <= end_line then - if line == start_line and line == end_line then - return col >= start_col and col < end_col - elseif line == start_line then - return col >= start_col - elseif line == end_line then - return col < end_col - else - return true - end - else - return false - end + return M.node_contains(node, { line, col, line, col }) end --- Determines if a node contains a range @@ -213,11 +201,11 @@ end --- ---@return boolean True if the {node} contains the {range} function M.node_contains(node, range) - local start_row, start_col, end_row, end_col = node:range() - local start_fits = start_row < range[1] or (start_row == range[1] and start_col <= range[2]) - local end_fits = end_row > range[3] or (end_row == range[3] and end_col >= range[4]) - - return start_fits and end_fits + vim.validate({ + node = { node, 'userdata' }, + range = { range, Range.validate, 'integer list with 4 or 6 elements' }, + }) + return Range.contains({ node:range() }, range) end --- Returns a list of highlight captures at the given position diff --git a/runtime/lua/vim/treesitter/_range.lua b/runtime/lua/vim/treesitter/_range.lua index 8decd3a1fd..bec24a23a5 100644 --- a/runtime/lua/vim/treesitter/_range.lua +++ b/runtime/lua/vim/treesitter/_range.lua @@ -54,6 +54,26 @@ M.cmp_pos = { setmetatable(M.cmp_pos, { __call = cmp_pos }) ---@private +---Check if a variable is a valid range object +---@param r any +---@return boolean +function M.validate(r) + if type(r) ~= 'table' or #r ~= 6 and #r ~= 4 then + return false + end + + for _, e in + ipairs(r --[[@as any[] ]]) + do + if type(e) ~= 'number' then + return false + end + end + + return true +end + +---@private ---@param r1 Range4|Range6 ---@param r2 Range4|Range6 ---@return boolean diff --git a/runtime/lua/vim/treesitter/languagetree.lua b/runtime/lua/vim/treesitter/languagetree.lua index 1bc7971eba..b8b0dd867e 100644 --- a/runtime/lua/vim/treesitter/languagetree.lua +++ b/runtime/lua/vim/treesitter/languagetree.lua @@ -1,3 +1,37 @@ +--- @defgroup lua-treesitter-languagetree +--- +--- @brief A \*LanguageTree\* contains a tree of parsers: the root treesitter parser for {lang} and +--- any "injected" language parsers, which themselves may inject other languages, recursively. +--- For example a Lua buffer containing some Vimscript commands needs multiple parsers to fully +--- understand its contents. +--- +--- To create a LanguageTree (parser object) for a given buffer and language, use: +--- +--- <pre>lua +--- local parser = vim.treesitter.get_parser(bufnr, lang) +--- </pre> +--- +--- (where `bufnr=0` means current buffer). `lang` defaults to 'filetype'. +--- Note: currently the parser is retained for the lifetime of a buffer but this may change; +--- a plugin should keep a reference to the parser object if it wants incremental updates. +--- +--- Whenever you need to access the current syntax tree, parse the buffer: +--- +--- <pre>lua +--- local tree = parser:parse() +--- </pre> +--- +--- This returns a table of immutable |treesitter-tree| objects representing the current state of +--- the buffer. When the plugin wants to access the state after a (possible) edit it must call +--- `parse()` again. If the buffer wasn't edited, the same tree will be returned again without extra +--- work. If the buffer was parsed before, incremental parsing will be done of the changed parts. +--- +--- Note: To use the parser directly inside a |nvim_buf_attach()| Lua callback, you must call +--- |vim.treesitter.get_parser()| before you register your callback. But preferably parsing +--- shouldn't be done directly in the change callback anyway as they will be very frequent. Rather +--- a plugin that does any kind of analysis on a tree should use a timer to throttle too frequent +--- updates. + local a = vim.api local query = require('vim.treesitter.query') local language = require('vim.treesitter.language') 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 |