diff options
Diffstat (limited to 'runtime/lua/vim')
-rw-r--r-- | runtime/lua/vim/F.lua | 7 | ||||
-rw-r--r-- | runtime/lua/vim/_meta.lua | 631 | ||||
-rw-r--r-- | runtime/lua/vim/lsp.lua | 52 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/buf.lua | 136 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/codelens.lua | 231 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/diagnostic.lua | 87 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/handlers.lua | 44 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/log.lua | 2 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/rpc.lua | 30 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/util.lua | 550 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter.lua | 38 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/health.lua | 4 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/highlighter.lua | 22 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/language.lua | 20 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/languagetree.lua | 160 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/query.lua | 181 |
16 files changed, 1807 insertions, 388 deletions
diff --git a/runtime/lua/vim/F.lua b/runtime/lua/vim/F.lua index 5887e978b9..7925ff6e44 100644 --- a/runtime/lua/vim/F.lua +++ b/runtime/lua/vim/F.lua @@ -20,5 +20,12 @@ function F.npcall(fn, ...) return F.ok_or_nil(pcall(fn, ...)) end +--- Wrap a function to return nil if it fails, otherwise the value +function F.nil_wrap(fn) + return function(...) + return F.npcall(fn, ...) + end +end + return F diff --git a/runtime/lua/vim/_meta.lua b/runtime/lua/vim/_meta.lua new file mode 100644 index 0000000000..02d1154df4 --- /dev/null +++ b/runtime/lua/vim/_meta.lua @@ -0,0 +1,631 @@ +-- prevents luacheck from making lints for setting things on vim +local vim = assert(vim) + +local a = vim.api +local validate = vim.validate + +local SET_TYPES = setmetatable({ + SET = 0, + LOCAL = 1, + GLOBAL = 2, +}, { __index = error }) + +local options_info = {} +for _, v in pairs(a.nvim_get_all_options_info()) do + options_info[v.name] = v + if v.shortname ~= "" then options_info[v.shortname] = v end +end + +local is_global_option = function(info) return info.scope == "global" end +local is_buffer_option = function(info) return info.scope == "buf" end +local is_window_option = function(info) return info.scope == "win" end + +local get_scoped_options = function(scope) + local result = {} + for name, option_info in pairs(options_info) do + if option_info.scope == scope then + result[name] = true + end + end + + return result +end + +local buf_options = get_scoped_options("buf") +local glb_options = get_scoped_options("global") +local win_options = get_scoped_options("win") + +local function make_meta_accessor(get, set, del, validator) + validator = validator or function() return true end + + validate { + get = {get, 'f'}; + set = {set, 'f'}; + del = {del, 'f', true}; + validator = {validator, 'f'}; + } + + local mt = {} + function mt:__newindex(k, v) + if not validator(k) then + return + end + + if del and v == nil then + return del(k) + end + return set(k, v) + end + function mt:__index(k) + if not validator(k) then + return + end + + return get(k) + end + return setmetatable({}, mt) +end + +vim.env = make_meta_accessor(function(k) + local v = vim.fn.getenv(k) + if v == vim.NIL then + return nil + end + return v +end, vim.fn.setenv) + +do -- buffer option accessor + local function new_buf_opt_accessor(bufnr) + local function get(k) + if bufnr == nil and type(k) == "number" then + return new_buf_opt_accessor(k) + end + + return a.nvim_buf_get_option(bufnr or 0, k) + end + + local function set(k, v) + return a.nvim_buf_set_option(bufnr or 0, k, v) + end + + return make_meta_accessor(get, set, nil, function(k) + if type(k) == 'string' then + if win_options[k] then + error(string.format([['%s' is a window option, not a buffer option. See ":help %s"]], k, k)) + elseif glb_options[k] then + error(string.format([['%s' is a global option, not a buffer option. See ":help %s"]], k, k)) + end + end + + return true + end) + end + + vim.bo = new_buf_opt_accessor(nil) +end + +do -- window option accessor + local function new_win_opt_accessor(winnr) + local function get(k) + if winnr == nil and type(k) == "number" then + return new_win_opt_accessor(k) + end + return a.nvim_win_get_option(winnr or 0, k) + end + + local function set(k, v) + return a.nvim_win_set_option(winnr or 0, k, v) + end + + return make_meta_accessor(get, set, nil, function(k) + if type(k) == 'string' then + if buf_options[k] then + error(string.format([['%s' is a buffer option, not a window option. See ":help %s"]], k, k)) + elseif glb_options[k] then + error(string.format([['%s' is a global option, not a window option. See ":help %s"]], k, k)) + end + end + + return true + end) + end + + vim.wo = new_win_opt_accessor(nil) +end + +--[[ +Local window setter + +buffer options: does not get copied when split + nvim_set_option(buf_opt, value) -> sets the default for NEW buffers + this sets the hidden global default for buffer options + + nvim_buf_set_option(...) -> sets the local value for the buffer + + set opt=value, does BOTH global default AND buffer local value + setlocal opt=value, does ONLY buffer local value + +window options: gets copied + does not need to call nvim_set_option because nobody knows what the heck this does⸮ + We call it anyway for more readable code. + + + Command global value local value + :set option=value set set + :setlocal option=value - set +:setglobal option=value set - +--]] +local function set_scoped_option(k, v, set_type) + local info = options_info[k] + + -- Don't let people do setlocal with global options. + -- That is a feature that doesn't make sense. + if set_type == SET_TYPES.LOCAL and is_global_option(info) then + error(string.format("Unable to setlocal option: '%s', which is a global option.", k)) + end + + -- Only `setlocal` skips setting the default/global value + -- This will more-or-less noop for window options, but that's OK + if set_type ~= SET_TYPES.LOCAL then + a.nvim_set_option(k, v) + end + + if is_window_option(info) then + if set_type ~= SET_TYPES.GLOBAL then + a.nvim_win_set_option(0, k, v) + end + elseif is_buffer_option(info) then + if set_type == SET_TYPES.LOCAL + or (set_type == SET_TYPES.SET and not info.global_local) then + a.nvim_buf_set_option(0, k, v) + end + end +end + +--[[ +Local window getter + + Command global value local value + :set option? - display + :setlocal option? - display +:setglobal option? display - +--]] +local function get_scoped_option(k, set_type) + local info = assert(options_info[k], "Must be a valid option: " .. tostring(k)) + + if set_type == SET_TYPES.GLOBAL or is_global_option(info) then + return a.nvim_get_option(k) + end + + if is_buffer_option(info) then + local was_set, value = pcall(a.nvim_buf_get_option, 0, k) + if was_set then return value end + + if info.global_local then + return a.nvim_get_option(k) + end + + error("buf_get: This should not be able to happen, given my understanding of options // " .. k) + end + + if is_window_option(info) then + if vim.api.nvim_get_option_info(k).was_set then + local was_set, value = pcall(a.nvim_win_get_option, 0, k) + if was_set then return value end + end + + return a.nvim_get_option(k) + end + + error("This fallback case should not be possible. " .. k) +end + +-- vim global option +-- this ONLY sets the global option. like `setglobal` +vim.go = make_meta_accessor(a.nvim_get_option, a.nvim_set_option) + +-- vim `set` style options. +-- it has no additional metamethod magic. +vim.o = make_meta_accessor( + function(k) return get_scoped_option(k, SET_TYPES.SET) end, + function(k, v) return set_scoped_option(k, v, SET_TYPES.SET) end +) + +---@brief [[ +--- vim.opt, vim.opt_local and vim.opt_global implementation +--- +--- To be used as helpers for working with options within neovim. +--- For information on how to use, see :help vim.opt +--- +---@brief ]] + +--- Preserves the order and does not mutate the original list +local remove_duplicate_values = function(t) + local result, seen = {}, {} + if type(t) == "function" then error(debug.traceback("asdf")) end + for _, v in ipairs(t) do + if not seen[v] then + table.insert(result, v) + end + + seen[v] = true + end + + return result +end + +-- TODO(tjdevries): Improve option metadata so that this doesn't have to be hardcoded. +-- Can be done in a separate PR. +local key_value_options = { + fillchars = true, + listchars = true, + winhl = true, +} + +---@class OptionType +--- Option Type Enum +local OptionTypes = setmetatable({ + BOOLEAN = 0, + NUMBER = 1, + STRING = 2, + ARRAY = 3, + MAP = 4, + SET = 5, +}, { + __index = function(_, k) error("Not a valid OptionType: " .. k) end, + __newindex = function(_, k) error("Cannot set a new OptionType: " .. k) end, +}) + +--- Convert a vimoption_T style dictionary to the correct OptionType associated with it. +---@return OptionType +local get_option_type = function(name, info) + if info.type == "boolean" then + return OptionTypes.BOOLEAN + elseif info.type == "number" then + return OptionTypes.NUMBER + elseif info.type == "string" then + if not info.commalist and not info.flaglist then + return OptionTypes.STRING + end + + if key_value_options[name] then + assert(info.commalist, "Must be a comma list to use key:value style") + return OptionTypes.MAP + end + + if info.flaglist then + return OptionTypes.SET + elseif info.commalist then + return OptionTypes.ARRAY + end + + error("Fallthrough in OptionTypes") + else + error("Not a known info.type:" .. info.type) + end +end + + +--- Convert a lua value to a vimoption_T value +local convert_value_to_vim = (function() + -- Map of functions to take a Lua style value and convert to vimoption_T style value. + -- Each function takes (info, lua_value) -> vim_value + local to_vim_value = { + [OptionTypes.BOOLEAN] = function(_, value) return value end, + [OptionTypes.NUMBER] = function(_, value) return value end, + [OptionTypes.STRING] = function(_, value) return value end, + + [OptionTypes.SET] = function(_, value) + if type(value) == "string" then return value end + local result = '' + for k in pairs(value) do + result = result .. k + end + + return result + end, + + [OptionTypes.ARRAY] = function(_, value) + if type(value) == "string" then return value end + return table.concat(remove_duplicate_values(value), ",") + end, + + [OptionTypes.MAP] = function(_, value) + if type(value) == "string" then return value end + if type(value) == "function" then error(debug.traceback("asdf")) end + + local result = {} + for opt_key, opt_value in pairs(value) do + table.insert(result, string.format("%s:%s", opt_key, opt_value)) + end + + table.sort(result) + return table.concat(result, ",") + end, + } + + return function(name, info, value) + return to_vim_value[get_option_type(name, info)](info, value) + end +end)() + +--- Converts a vimoption_T style value to a Lua value +local convert_value_to_lua = (function() + -- Map of OptionType to functions that take vimoption_T values and conver to lua values. + -- Each function takes (info, vim_value) -> lua_value + local to_lua_value = { + [OptionTypes.BOOLEAN] = function(_, value) return value end, + [OptionTypes.NUMBER] = function(_, value) return value end, + [OptionTypes.STRING] = function(_, value) return value end, + + [OptionTypes.ARRAY] = function(_, value) + if type(value) == "table" then + value = remove_duplicate_values(value) + return value + end + + return vim.split(value, ",") + end, + + [OptionTypes.SET] = function(info, value) + if type(value) == "table" then return value end + + assert(info.flaglist, "That is the only one I know how to handle") + + local result = {} + for i = 1, #value do + result[value:sub(i, i)] = true + end + + return result + end, + + [OptionTypes.MAP] = function(info, raw_value) + if type(raw_value) == "table" then return raw_value end + + assert(info.commalist, "Only commas are supported currently") + + local result = {} + + local comma_split = vim.split(raw_value, ",") + for _, key_value_str in ipairs(comma_split) do + local key, value = unpack(vim.split(key_value_str, ":")) + key = vim.trim(key) + value = vim.trim(value) + + result[key] = value + end + + return result + end, + } + + return function(name, info, option_value) + return to_lua_value[get_option_type(name, info)](info, option_value) + end +end)() + +--- Handles the mutation of various different values. +local value_mutator = function(name, info, current, new, mutator) + return mutator[get_option_type(name, info)](current, new) +end + +--- Handles the '^' operator +local prepend_value = (function() + local methods = { + [OptionTypes.NUMBER] = function() + error("The '^' operator is not currently supported for") + end, + + [OptionTypes.STRING] = function(left, right) + return right .. left + end, + + [OptionTypes.ARRAY] = function(left, right) + for i = #right, 1, -1 do + table.insert(left, 1, right[i]) + end + + return left + end, + + [OptionTypes.MAP] = function(left, right) + return vim.tbl_extend("force", left, right) + end, + + [OptionTypes.SET] = function(left, right) + return vim.tbl_extend("force", left, right) + end, + } + + return function(name, info, current, new) + return value_mutator( + name, info, convert_value_to_lua(name, info, current), convert_value_to_lua(name, info, new), methods + ) + end +end)() + +--- Handles the '+' operator +local add_value = (function() + local methods = { + [OptionTypes.NUMBER] = function(left, right) + return left + right + end, + + [OptionTypes.STRING] = function(left, right) + return left .. right + end, + + [OptionTypes.ARRAY] = function(left, right) + for _, v in ipairs(right) do + table.insert(left, v) + end + + return left + end, + + [OptionTypes.MAP] = function(left, right) + return vim.tbl_extend("force", left, right) + end, + + [OptionTypes.SET] = function(left, right) + return vim.tbl_extend("force", left, right) + end, + } + + return function(name, info, current, new) + return value_mutator( + name, info, convert_value_to_lua(name, info, current), convert_value_to_lua(name, info, new), methods + ) + end +end)() + +--- Handles the '-' operator +local remove_value = (function() + local remove_one_item = function(t, val) + if vim.tbl_islist(t) then + local remove_index = nil + for i, v in ipairs(t) do + if v == val then + remove_index = i + end + end + + if remove_index then + table.remove(t, remove_index) + end + else + t[val] = nil + end + end + + local methods = { + [OptionTypes.NUMBER] = function(left, right) + return left - right + end, + + [OptionTypes.STRING] = function() + error("Subtraction not supported for strings.") + end, + + [OptionTypes.ARRAY] = function(left, right) + if type(right) == "string" then + remove_one_item(left, right) + else + for _, v in ipairs(right) do + remove_one_item(left, v) + end + end + + return left + end, + + [OptionTypes.MAP] = function(left, right) + if type(right) == "string" then + left[right] = nil + else + for _, v in ipairs(right) do + left[v] = nil + end + end + + return left + end, + + [OptionTypes.SET] = function(left, right) + if type(right) == "string" then + left[right] = nil + else + for _, v in ipairs(right) do + left[v] = nil + end + end + + return left + end, + } + + return function(name, info, current, new) + return value_mutator(name, info, convert_value_to_lua(name, info, current), new, methods) + end +end)() + +local create_option_metatable = function(set_type) + local set_mt, option_mt + + local make_option = function(name, value) + local info = assert(options_info[name], "Not a valid option name: " .. name) + + if type(value) == "table" and getmetatable(value) == option_mt then + assert(name == value._name, "must be the same value, otherwise that's weird.") + + value = value._value + end + + return setmetatable({ + _name = name, + _value = value, + _info = info, + }, option_mt) + end + + -- TODO(tjdevries): consider supporting `nil` for set to remove the local option. + -- vim.cmd [[set option<]] + + option_mt = { + -- To set a value, instead use: + -- opt[my_option] = value + _set = function(self) + local value = convert_value_to_vim(self._name, self._info, self._value) + set_scoped_option(self._name, value, set_type) + + return self + end, + + get = function(self) + return convert_value_to_lua(self._name, self._info, self._value) + end, + + append = function(self, right) + return self:__add(right):_set() + end, + + __add = function(self, right) + return make_option(self._name, add_value(self._name, self._info, self._value, right)) + end, + + prepend = function(self, right) + return self:__pow(right):_set() + end, + + __pow = function(self, right) + return make_option(self._name, prepend_value(self._name, self._info, self._value, right)) + end, + + remove = function(self, right) + return self:__sub(right):_set() + end, + + __sub = function(self, right) + return make_option(self._name, remove_value(self._name, self._info, self._value, right)) + end + } + option_mt.__index = option_mt + + set_mt = { + __index = function(_, k) + return make_option(k, get_scoped_option(k, set_type)) + end, + + __newindex = function(_, k, v) + local opt = make_option(k, v) + opt:_set() + end, + } + + return set_mt +end + +vim.opt = setmetatable({}, create_option_metatable(SET_TYPES.SET)) +vim.opt_local = setmetatable({}, create_option_metatable(SET_TYPES.LOCAL)) +vim.opt_global = setmetatable({}, create_option_metatable(SET_TYPES.GLOBAL)) diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 26700288af..75faf9bcc7 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -20,6 +20,7 @@ local lsp = { buf = require'vim.lsp.buf'; diagnostic = require'vim.lsp.diagnostic'; + codelens = require'vim.lsp.codelens'; util = util; -- Allow raw RPC access. @@ -483,6 +484,13 @@ end --- result. You can use this with `client.cancel_request(request_id)` --- to cancel the request. --- +--- - request_sync(method, params, timeout_ms, bufnr) +--- Sends a request to the server and synchronously waits for the response. +--- This is a wrapper around {client.request} +--- Returns: { err=err, result=result }, a dictionary, where `err` and `result` come from +--- the |lsp-handler|. On timeout, cancel or error, returns `(nil, err)` where `err` is a +--- string describing the failure reason. If the request was unsuccessful returns `nil`. +--- --- - notify(method, params) --- Sends a notification to an LSP server. --- Returns: a boolean to indicate if the notification was successful. If @@ -891,6 +899,42 @@ function lsp.start_client(config) end --@private + --- Sends a request to the server and synchronously waits for the response. + --- + --- This is a wrapper around {client.request} + --- + --@param method (string) LSP method name. + --@param params (table) LSP request params. + --@param timeout_ms (number, optional, default=1000) Maximum time in + ---milliseconds to wait for a result. + --@param bufnr (number) Buffer handle (0 for current). + --@returns { err=err, result=result }, a dictionary, where `err` and `result` come from the |lsp-handler|. + ---On timeout, cancel or error, returns `(nil, err)` where `err` is a + ---string describing the failure reason. If the request was unsuccessful + ---returns `nil`. + --@see |vim.lsp.buf_request_sync()| + function client.request_sync(method, params, timeout_ms, bufnr) + local request_result = nil + local function _sync_handler(err, _, result) + request_result = { err = err, result = result } + end + + local success, request_id = client.request(method, params, _sync_handler, + bufnr) + if not success then return nil end + + local wait_result, reason = vim.wait(timeout_ms or 1000, function() + return request_result ~= nil + end, 10) + + if not wait_result then + client.cancel_request(request_id) + return nil, wait_result_reason[reason] + end + return request_result + end + + --@private --- Sends a notification to an LSP server. --- --@param method (string) LSP method name. @@ -1289,12 +1333,12 @@ end --- --- Calls |vim.lsp.buf_request_all()| but blocks Nvim while awaiting the result. --- Parameters are the same as |vim.lsp.buf_request()| but the return result is ---- different. Wait maximum of {timeout_ms} (default 100) ms. +--- different. Wait maximum of {timeout_ms} (default 1000) ms. --- --@param bufnr (number) Buffer handle, or 0 for current. --@param method (string) LSP method name --@param params (optional, table) Parameters to send to the server ---@param timeout_ms (optional, number, default=100) Maximum time in +--@param timeout_ms (optional, number, default=1000) Maximum time in --- milliseconds to wait for a result. --- --@returns Map of client_id:request_result. On timeout, cancel or error, @@ -1307,7 +1351,7 @@ function lsp.buf_request_sync(bufnr, method, params, timeout_ms) request_results = it end) - local wait_result, reason = vim.wait(timeout_ms or 100, function() + local wait_result, reason = vim.wait(timeout_ms or 1000, function() return request_results ~= nil end, 10) @@ -1378,7 +1422,7 @@ function lsp.omnifunc(findstart, base) local items = {} lsp.buf_request(bufnr, 'textDocument/completion', params, function(err, _, result) - if err or not result then return end + if err or not result or vim.fn.mode() ~= "i" then return end local matches = util.text_document_completion_list_to_complete_items(result, prefix) -- TODO(ashkan): is this the best way to do this? vim.list_extend(items, matches) diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 31116985e2..c63b947c99 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -111,6 +111,39 @@ function M.completion(context) return request('textDocument/completion', params) end +--@private +--- If there is more than one client that supports the given method, +--- asks the user to select one. +-- +--@returns The client that the user selected or nil +local function select_client(method) + local clients = vim.tbl_values(vim.lsp.buf_get_clients()); + clients = vim.tbl_filter(function (client) + return client.supports_method(method) + end, clients) + -- better UX when choices are always in the same order (between restarts) + table.sort(clients, function (a, b) return a.name < b.name end) + + if #clients > 1 then + local choices = {} + for k,v in ipairs(clients) do + table.insert(choices, string.format("%d %s", k, v.name)) + end + local user_choice = vim.fn.confirm( + "Select a language server:", + table.concat(choices, "\n"), + 0, + "Question" + ) + if user_choice == 0 then return nil end + return clients[user_choice] + elseif #clients < 1 then + return nil + else + return clients[1] + end +end + --- Formats the current buffer. --- --@param options (optional, table) Can be used to specify FormattingOptions. @@ -119,8 +152,11 @@ end -- --@see https://microsoft.github.io/language-server-protocol/specification#textDocument_formatting function M.formatting(options) + local client = select_client("textDocument/formatting") + if client == nil then return end + local params = util.make_formatting_params(options) - return request('textDocument/formatting', params) + return client.request("textDocument/formatting", params) end --- Performs |vim.lsp.buf.formatting()| synchronously. @@ -134,14 +170,62 @@ end --- --@param options Table with valid `FormattingOptions` entries --@param timeout_ms (number) Request timeout +--@see |vim.lsp.buf.formatting_seq_sync| function M.formatting_sync(options, timeout_ms) + local client = select_client("textDocument/formatting") + if client == nil then return end + local params = util.make_formatting_params(options) - local result = vim.lsp.buf_request_sync(0, "textDocument/formatting", params, timeout_ms) - if not result or vim.tbl_isempty(result) then return end - local _, formatting_result = next(result) - result = formatting_result.result - if not result then return end - vim.lsp.util.apply_text_edits(result) + local result, err = client.request_sync("textDocument/formatting", params, timeout_ms) + if result and result.result then + util.apply_text_edits(result.result) + elseif err then + vim.notify("vim.lsp.buf.formatting_sync: " .. err, vim.log.levels.WARN) + end +end + +--- Formats the current buffer by sequentially requesting formatting from attached clients. +--- +--- Useful when multiple clients with formatting capability are attached. +--- +--- Since it's synchronous, can be used for running on save, to make sure buffer is formatted +--- prior to being saved. {timeout_ms} is passed on to the |vim.lsp.client| `request_sync` method. +--- Example: +--- <pre> +--- vim.api.nvim_command[[autocmd BufWritePre <buffer> lua vim.lsp.buf.formatting_seq_sync()]] +--- </pre> +--- +--@param options (optional, table) `FormattingOptions` entries +--@param timeout_ms (optional, number) Request timeout +--@param order (optional, table) List of client names. Formatting is requested from clients +---in the following order: first all clients that are not in the `order` list, then +---the remaining clients in the order as they occur in the `order` list. +function M.formatting_seq_sync(options, timeout_ms, order) + local clients = vim.tbl_values(vim.lsp.buf_get_clients()); + + -- sort the clients according to `order` + for _, client_name in ipairs(order or {}) do + -- if the client exists, move to the end of the list + for i, client in ipairs(clients) do + if client.name == client_name then + table.insert(clients, table.remove(clients, i)) + break + end + end + end + + -- loop through the clients and make synchronous formatting requests + for _, client in ipairs(clients) do + if client.resolved_capabilities.document_formatting then + local params = util.make_formatting_params(options) + local result, err = client.request_sync("textDocument/formatting", params, timeout_ms) + if result and result.result then + util.apply_text_edits(result.result) + elseif err then + vim.notify(string.format("vim.lsp.buf.formatting_seq_sync: (%s) %s", client.name, err), vim.log.levels.WARN) + end + end + end end --- Formats a given range. @@ -152,15 +236,12 @@ end --@param end_pos ({number, number}, optional) mark-indexed position. ---Defaults to the end of the last visual selection. function M.range_formatting(options, start_pos, end_pos) - validate { options = {options, 't', true} } - local sts = vim.bo.softtabstop; - options = vim.tbl_extend('keep', options or {}, { - tabSize = (sts > 0 and sts) or (sts < 0 and vim.bo.shiftwidth) or vim.bo.tabstop; - insertSpaces = vim.bo.expandtab; - }) + local client = select_client("textDocument/rangeFormatting") + if client == nil then return end + local params = util.make_given_range_params(start_pos, end_pos) - params.options = options - return request('textDocument/rangeFormatting', params) + params.options = util.make_formatting_params(options).options + return client.request("textDocument/rangeFormatting", params) end --- Renames all references to the symbol under the cursor. @@ -216,26 +297,31 @@ local function pick_call_hierarchy_item(call_hierarchy_items) return choice end +--@private +local function call_hierarchy(method) + local params = util.make_position_params() + request('textDocument/prepareCallHierarchy', params, function(err, _, result) + if err then + vim.notify(err.message, vim.log.levels.WARN) + return + end + local call_hierarchy_item = pick_call_hierarchy_item(result) + vim.lsp.buf_request(0, method, { item = call_hierarchy_item }) + end) +end + --- Lists all the call sites of the symbol under the cursor in the --- |quickfix| window. If the symbol can resolve to multiple --- items, the user can pick one in the |inputlist|. function M.incoming_calls() - local params = util.make_position_params() - request('textDocument/prepareCallHierarchy', params, function(_, _, result) - local call_hierarchy_item = pick_call_hierarchy_item(result) - vim.lsp.buf_request(0, 'callHierarchy/incomingCalls', { item = call_hierarchy_item }) - end) + call_hierarchy('callHierarchy/incomingCalls') end --- Lists all the items that are called by the symbol under the --- cursor in the |quickfix| window. If the symbol can resolve to --- multiple items, the user can pick one in the |inputlist|. function M.outgoing_calls() - local params = util.make_position_params() - request('textDocument/prepareCallHierarchy', params, function(_, _, result) - local call_hierarchy_item = pick_call_hierarchy_item(result) - vim.lsp.buf_request(0, 'callHierarchy/outgoingCalls', { item = call_hierarchy_item }) - end) + call_hierarchy('callHierarchy/outgoingCalls') end --- List workspace folders. diff --git a/runtime/lua/vim/lsp/codelens.lua b/runtime/lua/vim/lsp/codelens.lua new file mode 100644 index 0000000000..fbd37e3830 --- /dev/null +++ b/runtime/lua/vim/lsp/codelens.lua @@ -0,0 +1,231 @@ +local util = require('vim.lsp.util') +local api = vim.api +local M = {} + +--- bufnr → true|nil +--- to throttle refreshes to at most one at a time +local active_refreshes = {} + +--- bufnr -> client_id -> lenses +local lens_cache_by_buf = setmetatable({}, { + __index = function(t, b) + local key = b > 0 and b or api.nvim_get_current_buf() + return rawget(t, key) + end +}) + +local namespaces = setmetatable({}, { + __index = function(t, key) + local value = api.nvim_create_namespace('vim_lsp_codelens:' .. key) + rawset(t, key, value) + return value + end; +}) + +--@private +M.__namespaces = namespaces + + +--@private +local function execute_lens(lens, bufnr, client_id) + local line = lens.range.start.line + api.nvim_buf_clear_namespace(bufnr, namespaces[client_id], line, line + 1) + + -- Need to use the client that returned the lens → must not use buf_request + local client = vim.lsp.get_client_by_id(client_id) + assert(client, 'Client is required to execute lens, client_id=' .. client_id) + client.request('workspace/executeCommand', lens.command, function(...) + local result = vim.lsp.handlers['workspace/executeCommand'](...) + M.refresh() + return result + end, bufnr) +end + + +--- Return all lenses for the given buffer +--- +---@return table (`CodeLens[]`) +function M.get(bufnr) + local lenses_by_client = lens_cache_by_buf[bufnr] + if not lenses_by_client then return {} end + local lenses = {} + for _, client_lenses in pairs(lenses_by_client) do + vim.list_extend(lenses, client_lenses) + end + return lenses +end + + +--- Run the code lens in the current line +--- +function M.run() + local line = api.nvim_win_get_cursor(0)[1] + local bufnr = api.nvim_get_current_buf() + local options = {} + local lenses_by_client = lens_cache_by_buf[bufnr] or {} + for client, lenses in pairs(lenses_by_client) do + for _, lens in pairs(lenses) do + if lens.range.start.line == (line - 1) then + table.insert(options, {client=client, lens=lens}) + end + end + end + if #options == 0 then + vim.notify('No executable codelens found at current line') + elseif #options == 1 then + local option = options[1] + execute_lens(option.lens, bufnr, option.client) + else + local options_strings = {"Code lenses:"} + for i, option in ipairs(options) do + table.insert(options_strings, string.format('%d. %s', i, option.lens.command.title)) + end + local choice = vim.fn.inputlist(options_strings) + if choice < 1 or choice > #options then + return + end + local option = options[choice] + execute_lens(option.lens, bufnr, option.client) + end +end + + +--- Display the lenses using virtual text +--- +---@param lenses table of lenses to display (`CodeLens[] | null`) +---@param bufnr number +---@param client_id number +function M.display(lenses, bufnr, client_id) + if not lenses or not next(lenses) then + return + end + local lenses_by_lnum = {} + for _, lens in pairs(lenses) do + local line_lenses = lenses_by_lnum[lens.range.start.line] + if not line_lenses then + line_lenses = {} + lenses_by_lnum[lens.range.start.line] = line_lenses + end + table.insert(line_lenses, lens) + end + local ns = namespaces[client_id] + local num_lines = api.nvim_buf_line_count(bufnr) + for i = 0, num_lines do + local line_lenses = lenses_by_lnum[i] + api.nvim_buf_clear_namespace(bufnr, ns, i, i + 1) + local chunks = {} + for _, lens in pairs(line_lenses or {}) do + local text = lens.command and lens.command.title or 'Unresolved lens ...' + table.insert(chunks, {text, 'LspCodeLens' }) + end + if #chunks > 0 then + api.nvim_buf_set_virtual_text(bufnr, ns, i, chunks, {}) + end + end +end + + +--- Store lenses for a specific buffer and client +--- +---@param lenses table of lenses to store (`CodeLens[] | null`) +---@param bufnr number +---@param client_id number +function M.save(lenses, bufnr, client_id) + local lenses_by_client = lens_cache_by_buf[bufnr] + if not lenses_by_client then + lenses_by_client = {} + lens_cache_by_buf[bufnr] = lenses_by_client + local ns = namespaces[client_id] + api.nvim_buf_attach(bufnr, false, { + on_detach = function(b) lens_cache_by_buf[b] = nil end, + on_lines = function(_, b, _, first_lnum, last_lnum) + api.nvim_buf_clear_namespace(b, ns, first_lnum, last_lnum) + end + }) + end + lenses_by_client[client_id] = lenses +end + + +--@private +local function resolve_lenses(lenses, bufnr, client_id, callback) + lenses = lenses or {} + local num_lens = vim.tbl_count(lenses) + if num_lens == 0 then + callback() + return + end + + --@private + local function countdown() + num_lens = num_lens - 1 + if num_lens == 0 then + callback() + end + end + local ns = namespaces[client_id] + local client = vim.lsp.get_client_by_id(client_id) + for _, lens in pairs(lenses or {}) do + if lens.command then + countdown() + else + client.request('codeLens/resolve', lens, function(_, _, result) + if result and result.command then + lens.command = result.command + -- Eager display to have some sort of incremental feedback + -- Once all lenses got resolved there will be a full redraw for all lenses + -- So that multiple lens per line are properly displayed + api.nvim_buf_set_virtual_text( + bufnr, + ns, + lens.range.start.line, + {{ lens.command.title, 'LspCodeLens' },}, + {} + ) + end + countdown() + end, bufnr) + end + end +end + + +--- |lsp-handler| for the method `textDocument/codeLens` +--- +function M.on_codelens(err, _, result, client_id, bufnr) + assert(not err, vim.inspect(err)) + + M.save(result, bufnr, client_id) + + -- Eager display for any resolved (and unresolved) lenses and refresh them + -- once resolved. + M.display(result, bufnr, client_id) + resolve_lenses(result, bufnr, client_id, function() + M.display(result, bufnr, client_id) + active_refreshes[bufnr] = nil + end) +end + + +--- Refresh the codelens for the current buffer +--- +--- It is recommended to trigger this using an autocmd or via keymap. +--- +--- <pre> +--- autocmd BufEnter,CursorHold,InsertLeave <buffer> lua vim.lsp.codelens.refresh() +--- </pre> +--- +function M.refresh() + local params = { + textDocument = util.make_text_document_params() + } + local bufnr = api.nvim_get_current_buf() + if active_refreshes[bufnr] then + return + end + active_refreshes[bufnr] = true + vim.lsp.buf_request(0, 'textDocument/codeLens', params) +end + + +return M diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index 6f2f846a3b..d4580885db 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -271,8 +271,12 @@ local function set_diagnostic_cache(diagnostics, bufnr, client_id) end -- Account for servers that place diagnostics on terminating newline if buf_line_count > 0 then - local start = diagnostic.range.start - start.line = math.min(start.line, buf_line_count - 1) + diagnostic.range.start.line = math.max(math.min( + diagnostic.range.start.line, buf_line_count - 1 + ), 0) + diagnostic.range["end"].line = math.max(math.min( + diagnostic.range["end"].line, buf_line_count - 1 + ), 0) end end @@ -317,9 +321,9 @@ function M.save(diagnostics, bufnr, client_id) -- Clean up our data when the buffer unloads. api.nvim_buf_attach(bufnr, false, { - on_detach = function(b) + on_detach = function(_, b) clear_diagnostic_cache(b, client_id) - _diagnostic_cleanup[bufnr][client_id] = nil + _diagnostic_cleanup[b][client_id] = nil end }) end @@ -330,15 +334,19 @@ end -- Diagnostic Retrieval {{{ ---- Get all diagnostics for all clients +--- Get all diagnostics for clients --- ----@return {bufnr: Diagnostic[]} -function M.get_all() +---@param client_id number Restrict included diagnostics to the client +--- If nil, diagnostics of all clients are included. +---@return table with diagnostics grouped by bufnr (bufnr: Diagnostic[]) +function M.get_all(client_id) local diagnostics_by_bufnr = {} for bufnr, buf_diagnostics in pairs(diagnostic_cache) do diagnostics_by_bufnr[bufnr] = {} - for _, client_diagnostics in pairs(buf_diagnostics) do - vim.list_extend(diagnostics_by_bufnr[bufnr], client_diagnostics) + for cid, client_diagnostics in pairs(buf_diagnostics) do + if client_id == nil or cid == client_id then + vim.list_extend(diagnostics_by_bufnr[bufnr], client_diagnostics) + end end end return diagnostics_by_bufnr @@ -1151,6 +1159,7 @@ function M.show_line_diagnostics(opts, bufnr, line_nr, client_id) end end + opts.focus_id = "line_diagnostics" local popup_bufnr, winnr = util.open_floating_preview(lines, 'plaintext', opts) for i, hi in ipairs(highlights) do local prefixlen, hiname = unpack(hi) @@ -1161,13 +1170,6 @@ function M.show_line_diagnostics(opts, bufnr, line_nr, client_id) return popup_bufnr, winnr end -local loclist_type_map = { - [DiagnosticSeverity.Error] = 'E', - [DiagnosticSeverity.Warning] = 'W', - [DiagnosticSeverity.Information] = 'I', - [DiagnosticSeverity.Hint] = 'I', -} - --- Clear diagnotics and diagnostic cache --- @@ -1196,44 +1198,29 @@ end --- - Exclusive severity to consider. Overrides {severity_limit} --- - {severity_limit}: (DiagnosticSeverity) --- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid. +--- - {workspace}: (boolean, default false) +--- - Set the list with workspace diagnostics function M.set_loclist(opts) opts = opts or {} - local open_loclist = if_nil(opts.open_loclist, true) - - local bufnr = vim.api.nvim_get_current_buf() - local buffer_diags = M.get(bufnr, opts.client_id) - - if opts.severity then - buffer_diags = filter_to_severity_limit(opts.severity, buffer_diags) - elseif opts.severity_limit then - buffer_diags = filter_by_severity_limit(opts.severity_limit, buffer_diags) - end - - local items = {} - local insert_diag = function(diag) - local pos = diag.range.start - local row = pos.line - local col = util.character_offset(bufnr, row, pos.character) - - local line = (api.nvim_buf_get_lines(bufnr, row, row + 1, false) or {""})[1] - - table.insert(items, { - bufnr = bufnr, - lnum = row + 1, - col = col + 1, - text = line .. " | " .. diag.message, - type = loclist_type_map[diag.severity or DiagnosticSeverity.Error] or 'E', - }) - end - - for _, diag in ipairs(buffer_diags) do - insert_diag(diag) + local current_bufnr = api.nvim_get_current_buf() + local diags = opts.workspace and M.get_all(opts.client_id) or { + [current_bufnr] = M.get(current_bufnr, opts.client_id) + } + local predicate = function(d) + local severity = to_severity(opts.severity) + if severity then + return d.severity == severity + end + severity = to_severity(opts.severity_limit) + if severity then + return d.severity == severity + end + return true end - - table.sort(items, function(a, b) return a.lnum < b.lnum end) - - util.set_loclist(items) + local items = util.diagnostics_to_items(diags, predicate) + local win_id = vim.api.nvim_get_current_win() + util.set_loclist(items, win_id) if open_loclist then vim.cmd [[lopen]] end diff --git a/runtime/lua/vim/lsp/handlers.lua b/runtime/lua/vim/lsp/handlers.lua index 525ec4ce5b..5d38150fc0 100644 --- a/runtime/lua/vim/lsp/handlers.lua +++ b/runtime/lua/vim/lsp/handlers.lua @@ -187,6 +187,10 @@ M['textDocument/publishDiagnostics'] = function(...) return require('vim.lsp.diagnostic').on_publish_diagnostics(...) end +M['textDocument/codeLens'] = function(...) + return require('vim.lsp.codelens').on_codelens(...) +end + --@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references M['textDocument/references'] = function(_, _, result) if not result then return end @@ -260,24 +264,18 @@ end --- - See |vim.api.nvim_open_win()| function M.hover(_, method, result, _, _, config) config = config or {} - local bufnr, winnr = util.focusable_float(method, function() - if not (result and result.contents) then - -- return { 'No information available' } - return - end - local markdown_lines = util.convert_input_to_markdown_lines(result.contents) - markdown_lines = util.trim_empty_lines(markdown_lines) - if vim.tbl_isempty(markdown_lines) then - -- return { 'No information available' } - return - end - local bufnr, winnr = util.fancy_floating_markdown(markdown_lines, { - border = config.border - }) - util.close_preview_autocmd({"CursorMoved", "BufHidden", "InsertCharPre"}, winnr) - return bufnr, winnr - end) - return bufnr, winnr + config.focus_id = method + if not (result and result.contents) then + -- return { 'No information available' } + return + end + local markdown_lines = util.convert_input_to_markdown_lines(result.contents) + markdown_lines = util.trim_empty_lines(markdown_lines) + if vim.tbl_isempty(markdown_lines) then + -- return { 'No information available' } + return + end + return util.open_floating_preview(markdown_lines, "markdown", config) end --@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover @@ -335,23 +333,21 @@ M['textDocument/implementation'] = location_handler --- - See |vim.api.nvim_open_win()| function M.signature_help(_, method, result, _, bufnr, config) config = config or {} + config.focus_id = method -- When use `autocmd CompleteDone <silent><buffer> lua vim.lsp.buf.signature_help()` to call signatureHelp handler -- If the completion item doesn't have signatures It will make noise. Change to use `print` that can use `<silent>` to ignore if not (result and result.signatures and result.signatures[1]) then print('No signature help available') return end - local lines = util.convert_signature_help_to_markdown_lines(result) + local ft = api.nvim_buf_get_option(bufnr, 'filetype') + local lines = util.convert_signature_help_to_markdown_lines(result, ft) lines = util.trim_empty_lines(lines) if vim.tbl_isempty(lines) then print('No signature help available') return end - local syntax = api.nvim_buf_get_option(bufnr, 'syntax') - local p_bufnr, _ = util.focusable_preview(method, function() - return lines, util.try_trim_markdown_code_blocks(lines), config - end) - api.nvim_buf_set_option(p_bufnr, 'syntax', syntax) + return util.open_floating_preview(lines, "markdown", config) end --@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_signatureHelp diff --git a/runtime/lua/vim/lsp/log.lua b/runtime/lua/vim/lsp/log.lua index 331e980e67..471a311c16 100644 --- a/runtime/lua/vim/lsp/log.lua +++ b/runtime/lua/vim/lsp/log.lua @@ -10,7 +10,7 @@ local log = {} -- Can be used to lookup the number from the name or the name from the number. -- Levels by name: 'trace', 'debug', 'info', 'warn', 'error' -- Level numbers begin with 'trace' at 0 -log.levels = vim.log.levels +log.levels = vim.deepcopy(vim.log.levels) -- Default log level is warn. local current_log_level = log.levels.WARN diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua index 1aa8326514..98835d6708 100644 --- a/runtime/lua/vim/lsp/rpc.lua +++ b/runtime/lua/vim/lsp/rpc.lua @@ -518,32 +518,38 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) send_response(decoded.id, err, result) end) -- This works because we are expecting vim.NIL here - elseif decoded.id and (decoded.result or decoded.error) then + elseif decoded.id and (decoded.result ~= vim.NIL or decoded.error ~= vim.NIL) then -- Server Result decoded.error = convert_NIL(decoded.error) decoded.result = convert_NIL(decoded.result) + -- We sent a number, so we expect a number. + local result_id = tonumber(decoded.id) + -- Do not surface RequestCancelled or ContentModified to users, it is RPC-internal. if decoded.error then + local mute_error = false if decoded.error.code == protocol.ErrorCodes.RequestCancelled then local _ = log.debug() and log.debug("Received cancellation ack", decoded) + mute_error = true elseif decoded.error.code == protocol.ErrorCodes.ContentModified then local _ = log.debug() and log.debug("Received content modified ack", decoded) + mute_error = true end - local result_id = tonumber(decoded.id) - -- Clear any callback since this is cancelled now. - -- This is safe to do assuming that these conditions hold: - -- - The server will not send a result callback after this cancellation. - -- - If the server sent this cancellation ACK after sending the result, the user of this RPC - -- client will ignore the result themselves. - if result_id then - message_callbacks[result_id] = nil + + if mute_error then + -- Clear any callback since this is cancelled now. + -- This is safe to do assuming that these conditions hold: + -- - The server will not send a result callback after this cancellation. + -- - If the server sent this cancellation ACK after sending the result, the user of this RPC + -- client will ignore the result themselves. + if result_id then + message_callbacks[result_id] = nil + end + return end - return end - -- We sent a number, so we expect a number. - local result_id = tonumber(decoded.id) local callback = message_callbacks[result_id] if callback then message_callbacks[result_id] = nil diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 92ec447b55..326005dac4 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -4,6 +4,7 @@ local validate = vim.validate local api = vim.api local list_extend = vim.list_extend local highlight = require 'vim.highlight' +local uv = vim.loop local npcall = vim.F.npcall local split = vim.split @@ -29,6 +30,16 @@ local default_border = { {" ", "NormalFloat"}, } + +local DiagnosticSeverity = protocol.DiagnosticSeverity +local loclist_type_map = { + [DiagnosticSeverity.Error] = 'E', + [DiagnosticSeverity.Warning] = 'W', + [DiagnosticSeverity.Information] = 'I', + [DiagnosticSeverity.Hint] = 'I', +} + + --@private -- Check the border given by opts or the default border for the additional -- size it adds to a float. @@ -39,14 +50,37 @@ local function get_border_size(opts) local width = 0 if type(border) == 'string' then - -- 'single', 'double', etc. - height = 2 - width = 2 + local border_size = {none = {0, 0}, single = {2, 2}, double = {2, 2}, rounded = {2, 2}, solid = {2, 2}, shadow = {1, 1}} + if border_size[border] == nil then + error("floating preview border is not correct. Please refer to the docs |vim.api.nvim_open_win()|" + .. vim.inspect(border)) + end + height, width = unpack(border_size[border]) else - height = height + vim.fn.strdisplaywidth(border[2][1]) -- top - height = height + vim.fn.strdisplaywidth(border[6][1]) -- bottom - width = width + vim.fn.strdisplaywidth(border[4][1]) -- right - width = width + vim.fn.strdisplaywidth(border[8][1]) -- left + local function border_width(id) + if type(border[id]) == "table" then + -- border specified as a table of <character, highlight group> + return vim.fn.strdisplaywidth(border[id][1]) + elseif type(border[id]) == "string" then + -- border specified as a list of border characters + return vim.fn.strdisplaywidth(border[id]) + end + error("floating preview border is not correct. Please refer to the docs |vim.api.nvim_open_win()|" .. vim.inspect(border)) + end + local function border_height(id) + if type(border[id]) == "table" then + -- border specified as a table of <character, highlight group> + return #border[id][1] > 0 and 1 or 0 + elseif type(border[id]) == "string" then + -- border specified as a list of border characters + return #border[id] > 0 and 1 or 0 + end + error("floating preview border is not correct. Please refer to the docs |vim.api.nvim_open_win()|" .. vim.inspect(border)) + end + height = height + border_height(2) -- top + height = height + border_height(6) -- bottom + width = width + border_width(4) -- right + width = width + border_width(8) -- left end return { height = height, width = width } @@ -73,7 +107,7 @@ function M.set_lines(lines, A, B, new_lines) -- way the LSP describes the range including the last newline is by -- specifying a line number after what we would call the last line. local i_n = math.min(B[1] + 1, #lines) - if not (i_0 >= 1 and i_0 <= #lines and i_n >= 1 and i_n <= #lines) then + if not (i_0 >= 1 and i_0 <= #lines + 1 and i_n >= 1 and i_n <= #lines) then error("Invalid range: "..vim.inspect{A = A; B = B; #lines, new_lines}) end local prefix = "" @@ -566,13 +600,15 @@ end --@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion local function get_completion_word(item) if item.textEdit ~= nil and item.textEdit.newText ~= nil and item.textEdit.newText ~= "" then - if protocol.InsertTextFormat[item.insertTextFormat] == "PlainText" then + local insert_text_format = protocol.InsertTextFormat[item.insertTextFormat] + if insert_text_format == "PlainText" or insert_text_format == nil then return item.textEdit.newText else return M.parse_snippet(item.textEdit.newText) end elseif item.insertText ~= nil and item.insertText ~= "" then - if protocol.InsertTextFormat[item.insertTextFormat] == "PlainText" then + local insert_text_format = protocol.InsertTextFormat[item.insertTextFormat] + if insert_text_format == "PlainText" or insert_text_format == nil then return item.insertText else return M.parse_snippet(item.insertText) @@ -770,14 +806,20 @@ function M.convert_input_to_markdown_lines(input, contents) assert(type(input) == 'table', "Expected a table for Hover.contents") -- MarkupContent if input.kind then - -- The kind can be either plaintext or markdown. However, either way we - -- will just be rendering markdown, so we handle them both the same way. - -- TODO these can have escaped/sanitized html codes in markdown. We - -- should make sure we handle this correctly. + -- The kind can be either plaintext or markdown. + -- If it's plaintext, then wrap it in a <text></text> block -- Some servers send input.value as empty, so let's ignore this :( + input.value = input.value or '' + + if input.kind == "plaintext" then + -- wrap this in a <text></text> block so that stylize_markdown + -- can properly process it as plaintext + input.value = string.format("<text>\n%s\n</text>", input.value or "") + end + -- assert(type(input.value) == 'string') - list_extend(contents, split_lines(input.value or '')) + list_extend(contents, split_lines(input.value)) -- MarkupString variation 2 elseif input.language then -- Some servers send input.value as empty, so let's ignore this :( @@ -802,9 +844,10 @@ end --- Converts `textDocument/SignatureHelp` response to markdown lines. --- --@param signature_help Response of `textDocument/SignatureHelp` +--@param ft optional filetype that will be use as the `lang` for the label markdown code block --@returns list of lines of converted markdown. --@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_signatureHelp -function M.convert_signature_help_to_markdown_lines(signature_help) +function M.convert_signature_help_to_markdown_lines(signature_help, ft) if not signature_help.signatures then return end @@ -822,7 +865,12 @@ function M.convert_signature_help_to_markdown_lines(signature_help) if not signature then return end - vim.list_extend(contents, vim.split(signature.label, '\n', true)) + local label = signature.label + if ft then + -- wrap inside a code block so fancy_markdown can render it properly + label = ("```%s\n%s\n```"):format(ft, label) + end + vim.list_extend(contents, vim.split(label, '\n', true)) if signature.documentation then M.convert_input_to_markdown_lines(signature.documentation, contents) end @@ -906,6 +954,7 @@ function M.make_floating_popup_options(width, height, opts) anchor = anchor, col = col + (opts.offset_x or 0), height = height, + focusable = opts.focusable, relative = 'cursor', row = row + (opts.offset_y or 0), style = 'minimal', @@ -914,23 +963,6 @@ function M.make_floating_popup_options(width, height, opts) } end -local function _should_add_to_tagstack(new_item) - local stack = vim.fn.gettagstack() - - -- Check if we're at the bottom of the tagstack. - if stack.curidx <= 1 then return true end - - local top_item = stack.items[stack.curidx-1] - - -- Check if the item at the top of the tagstack is exactly the - -- same as the one we want to push. - if top_item.tagname ~= new_item.tagname then return true end - for i, v in ipairs(top_item.from) do - if v ~= new_item.from[i] then return true end - end - return false -end - --- Jumps to a location. --- --@param location (`Location`|`LocationLink`) @@ -939,33 +971,22 @@ function M.jump_to_location(location) -- location may be Location or LocationLink local uri = location.uri or location.targetUri if uri == nil then return end + local bufnr = vim.uri_to_bufnr(uri) + -- Save position in jumplist + vim.cmd "normal! m'" - local from_bufnr = vim.fn.bufnr('%') - local from = {from_bufnr, vim.fn.line('.'), vim.fn.col('.'), 0} - local item = {tagname=vim.fn.expand('<cword>'), from=from} + -- Push a new item into tagstack + local from = {vim.fn.bufnr('%'), vim.fn.line('.'), vim.fn.col('.'), 0} + local items = {{tagname=vim.fn.expand('<cword>'), from=from}} + vim.fn.settagstack(vim.fn.win_getid(), {items=items}, 't') --- Jump to new location (adjusting for UTF-16 encoding of characters) - local bufnr = vim.uri_to_bufnr(uri) api.nvim_set_current_buf(bufnr) api.nvim_buf_set_option(0, 'buflisted', true) local range = location.range or location.targetSelectionRange local row = range.start.line local col = get_line_byte_from_position(0, range.start) - -- This prevents the tagstack to be filled with items that provide - -- no motion when CTRL-T is pressed because they're both the source - -- and the destination. - local motionless = - bufnr == from_bufnr and - row+1 == from[2] and col+1 == from[3] - if not motionless and _should_add_to_tagstack(item) then - local winid = vim.fn.win_getid() - local items = {item} - vim.fn.settagstack(winid, {items=items}, 't') - end - - -- Jump to new location api.nvim_win_set_cursor(0, {row + 1, col}) - return true end @@ -977,7 +998,7 @@ end --- --@param location a single `Location` or `LocationLink` --@returns (bufnr,winnr) buffer and window number of floating window or nil -function M.preview_location(location) +function M.preview_location(location, opts) -- location may be LocationLink or Location (more useful for the former) local uri = location.targetUri or location.uri if uri == nil then return end @@ -988,7 +1009,15 @@ function M.preview_location(location) local range = location.targetRange or location.range local contents = api.nvim_buf_get_lines(bufnr, range.start.line, range["end"].line+1, false) local syntax = api.nvim_buf_get_option(bufnr, 'syntax') - return M.open_floating_preview(contents, syntax) + if syntax == "" then + -- When no syntax is set, we use filetype as fallback. This might not result + -- in a valid syntax definition. See also ft detection in fancy_floating_win. + -- An empty syntax is more common now with TreeSitter, since TS disables syntax. + syntax = api.nvim_buf_get_option(bufnr, 'filetype') + end + opts = opts or {} + opts.focus_id = "location" + return M.open_floating_preview(contents, syntax, opts) end --@private @@ -1010,7 +1039,9 @@ end ---buffer id, the newly created window will be the new focus associated with ---the current buffer via the tag `unique_name`. --@returns (pbufnr, pwinnr) if `fn()` has created a new window; nil otherwise +---@deprecated please use open_floating_preview directly function M.focusable_float(unique_name, fn) + vim.notify("focusable_float is deprecated. Please use open_floating_preview and pass focus_id = [unique_name] instead", vim.log.levels.WARN) -- Go back to previous window if we are in a focusable one if npcall(api.nvim_win_get_var, 0, unique_name) then return api.nvim_command("wincmd p") @@ -1039,10 +1070,10 @@ end --@param fn (function) The return values of this function will be passed ---directly to |vim.lsp.util.open_floating_preview()|, in the case that a new ---floating window should be created +---@deprecated please use open_floating_preview directly function M.focusable_preview(unique_name, fn) - return M.focusable_float(unique_name, function() - return M.open_floating_preview(fn()) - end) + vim.notify("focusable_preview is deprecated. Please use open_floating_preview and pass focus_id = [unique_name] instead", vim.log.levels.WARN) + return M.open_floating_preview(fn(), {focus_id = unique_name}) end --- Trims empty lines from input and pad top and bottom with empty lines @@ -1074,13 +1105,20 @@ end --- TODO: refactor to separate stripping/converting and make use of open_floating_preview --- +--- @deprecated please use open_floating_preview directly +function M.fancy_floating_markdown(contents, opts) + vim.notify("fancy_floating_markdown is deprecated. Please use open_floating_preview and pass focus_id = [unique_name] instead", vim.log.levels.WARN) + return M.open_floating_preview(contents, "markdown", opts) +end + --- Converts markdown into syntax highlighted regions by stripping the code --- blocks and converting them into highlighted code. --- This will by default insert a blank line separator after those code block --- regions to improve readability. ---- The result is shown in a floating preview. +--- +--- This method configures the given buffer and returns the lines to set. +--- +--- If you want to open a popup with fancy markdown, use `open_floating_preview` instead --- ---@param contents table of lines to show in window ---@param opts dictionary with optional fields @@ -1095,29 +1133,57 @@ end --- - pad_bottom number of lines to pad contents at bottom --- - separator insert separator after code block ---@returns width,height size of float -function M.fancy_floating_markdown(contents, opts) +function M.stylize_markdown(bufnr, contents, opts) validate { contents = { contents, 't' }; opts = { opts, 't', true }; } opts = opts or {} + -- table of fence types to {ft, begin, end} + -- when ft is nil, we get the ft from the regex match + local matchers = { + block = {nil, "```+([a-zA-Z0-9_]*)", "```+"}, + pre = {"", "<pre>", "</pre>"}, + code = {"", "<code>", "</code>"}, + text = {"plaintex", "<text>", "</text>"}, + } + + local match_begin = function(line) + for type, pattern in pairs(matchers) do + local ret = line:match(string.format("^%%s*%s%%s*$", pattern[2])) + if ret then + return { + type = type, + ft = pattern[1] or ret + } + end + end + end + + local match_end = function(line, match) + local pattern = matchers[match.type] + return line:match(string.format("^%%s*%s%%s*$", pattern[3])) + end + + -- Clean up + contents = M._trim(contents, opts) + local stripped = {} local highlights = {} + -- keep track of lnums that contain markdown + local markdown_lines = {} do local i = 1 while i <= #contents do local line = contents[i] - -- TODO(ashkan): use a more strict regex for filetype? - local ft = line:match("^```([a-zA-Z0-9_]*)$") - -- local ft = line:match("^```(.*)$") - -- TODO(ashkan): validate the filetype here. - if ft then + local match = match_begin(line) + if match then local start = #stripped i = i + 1 while i <= #contents do line = contents[i] - if line == "```" then + if match_end(line, match) then i = i + 1 break end @@ -1125,76 +1191,96 @@ function M.fancy_floating_markdown(contents, opts) i = i + 1 end table.insert(highlights, { - ft = ft; + ft = match.ft; start = start + 1; finish = #stripped + 1 - 1; }) else table.insert(stripped, line) + markdown_lines[#stripped] = true i = i + 1 end end end - -- Clean up - stripped = M._trim(stripped, opts) -- Compute size of float needed to show (wrapped) lines opts.wrap_at = opts.wrap_at or (vim.wo["wrap"] and api.nvim_win_get_width(0)) local width, height = M._make_floating_popup_size(stripped, opts) + local sep_line = string.rep("─", math.min(width, opts.wrap_at or width)) + + for l in pairs(markdown_lines) do + if stripped[l]:match("^---+$") then + stripped[l] = sep_line + end + end + -- Insert blank line separator after code block local insert_separator = opts.separator if insert_separator == nil then insert_separator = true end if insert_separator then - for i, h in ipairs(highlights) do - h.start = h.start + i - 1 - h.finish = h.finish + i - 1 + local offset = 0 + for _, h in ipairs(highlights) do + h.start = h.start + offset + h.finish = h.finish + offset + -- check if a seperator already exists and use that one instead of creating a new one if h.finish + 1 <= #stripped then - table.insert(stripped, h.finish + 1, string.rep("─", math.min(width, opts.wrap_at or width))) - height = height + 1 + if stripped[h.finish + 1] ~= sep_line then + table.insert(stripped, h.finish + 1, sep_line) + offset = offset + 1 + height = height + 1 + end end end end - -- Make the floating window. - local bufnr = api.nvim_create_buf(false, true) - local winnr = api.nvim_open_win(bufnr, false, M.make_floating_popup_options(width, height, opts)) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, stripped) - api.nvim_buf_set_option(bufnr, 'modifiable', false) - -- Switch to the floating window to apply the syntax highlighting. - -- This is because the syntax command doesn't accept a target. - local cwin = vim.api.nvim_get_current_win() - vim.api.nvim_set_current_win(winnr) - api.nvim_win_set_option(winnr, 'conceallevel', 2) - api.nvim_win_set_option(winnr, 'concealcursor', 'n') + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, stripped) - vim.cmd("ownsyntax lsp_markdown") local idx = 1 --@private + -- keep track of syntaxes we already inlcuded. + -- no need to include the same syntax more than once + local langs = {} local function apply_syntax_to_region(ft, start, finish) - if ft == '' then return end + if ft == "" then + vim.cmd(string.format("syntax region markdownCode start=+\\%%%dl+ end=+\\%%%dl+ keepend extend", start, finish + 1)) + return + end local name = ft..idx idx = idx + 1 local lang = "@"..ft:upper() - -- TODO(ashkan): better validation before this. - if not pcall(vim.cmd, string.format("syntax include %s syntax/%s.vim", lang, ft)) then - return + if not langs[lang] then + -- HACK: reset current_syntax, since some syntax files like markdown won't load if it is already set + pcall(vim.api.nvim_buf_del_var, bufnr, "current_syntax") + -- TODO(ashkan): better validation before this. + if not pcall(vim.cmd, string.format("syntax include %s syntax/%s.vim", lang, ft)) then + return + end + langs[lang] = true end - vim.cmd(string.format("syntax region %s start=+\\%%%dl+ end=+\\%%%dl+ contains=%s", name, start, finish + 1, lang)) - end - -- Previous highlight region. - -- TODO(ashkan): this wasn't working for some reason, but I would like to - -- make sure that regions between code blocks are definitely markdown. - -- local ph = {start = 0; finish = 1;} - for _, h in ipairs(highlights) do - -- apply_syntax_to_region('markdown', ph.finish, h.start) - apply_syntax_to_region(h.ft, h.start, h.finish) - -- ph = h + vim.cmd(string.format("syntax region %s start=+\\%%%dl+ end=+\\%%%dl+ contains=%s keepend", name, start, finish + 1, lang)) end - vim.api.nvim_set_current_win(cwin) - return bufnr, winnr + -- needs to run in the buffer for the regions to work + api.nvim_buf_call(bufnr, function() + -- we need to apply lsp_markdown regions speperately, since otherwise + -- markdown regions can "bleed" through the other syntax regions + -- and mess up the formatting + local last = 1 + for _, h in ipairs(highlights) do + if last < h.start then + apply_syntax_to_region("lsp_markdown", last, h.start - 1) + end + apply_syntax_to_region(h.ft, h.start, h.finish) + last = h.finish + 1 + end + if last < #stripped then + apply_syntax_to_region("lsp_markdown", last, #stripped) + end + end) + + return stripped end --- Creates autocommands to close a preview window when events happen. @@ -1203,7 +1289,9 @@ end --@param winnr (number) window id of preview window --@see |autocmd-events| function M.close_preview_autocmd(events, winnr) - api.nvim_command("autocmd "..table.concat(events, ',').." <buffer> ++once lua pcall(vim.api.nvim_win_close, "..winnr..", true)") + if #events > 0 then + api.nvim_command("autocmd "..table.concat(events, ',').." <buffer> ++once lua pcall(vim.api.nvim_win_close, "..winnr..", true)") + end end --@internal @@ -1286,15 +1374,19 @@ end --@param contents table of lines to show in window --@param syntax string of syntax to set for opened buffer --@param opts dictionary with optional fields --- - height of floating window --- - width of floating window --- - wrap_at character to wrap at for computing height --- - max_width maximal width of floating window --- - max_height maximal height of floating window --- - pad_left number of columns to pad contents at left --- - pad_right number of columns to pad contents at right --- - pad_top number of lines to pad contents at top --- - pad_bottom number of lines to pad contents at bottom +--- - height of floating window +--- - width of floating window +--- - wrap boolean enable wrapping of long lines (defaults to true) +--- - wrap_at character to wrap at for computing height when wrap is enabled +--- - max_width maximal width of floating window +--- - max_height maximal height of floating window +--- - pad_left number of columns to pad contents at left +--- - pad_right number of columns to pad contents at right +--- - pad_top number of lines to pad contents at top +--- - pad_bottom number of lines to pad contents at bottom +--- - focus_id if a popup with this id is opened, then focus it +--- - close_events list of events that closes the floating window +--- - focusable (boolean, default true): Make float focusable --@returns bufnr,winnr buffer and window number of the newly created floating ---preview window function M.open_floating_preview(contents, syntax, opts) @@ -1304,27 +1396,85 @@ function M.open_floating_preview(contents, syntax, opts) opts = { opts, 't', true }; } opts = opts or {} + opts.wrap = opts.wrap ~= false -- wrapping by default + opts.stylize_markdown = opts.stylize_markdown ~= false + opts.close_events = opts.close_events or {"CursorMoved", "CursorMovedI", "BufHidden", "InsertCharPre"} + + local bufnr = api.nvim_get_current_buf() + + -- check if this popup is focusable and we need to focus + if opts.focus_id and opts.focusable ~= false then + -- Go back to previous window if we are in a focusable one + local current_winnr = api.nvim_get_current_win() + if npcall(api.nvim_win_get_var, current_winnr, opts.focus_id) then + api.nvim_command("wincmd p") + return bufnr, current_winnr + end + do + local win = find_window_by_var(opts.focus_id, bufnr) + if win and api.nvim_win_is_valid(win) and vim.fn.pumvisible() == 0 then + -- focus and return the existing buf, win + api.nvim_set_current_win(win) + api.nvim_command("stopinsert") + return api.nvim_win_get_buf(win), win + end + end + end + + -- check if another floating preview already exists for this buffer + -- and close it if needed + local existing_float = npcall(api.nvim_buf_get_var, bufnr, "lsp_floating_preview") + if existing_float and api.nvim_win_is_valid(existing_float) then + api.nvim_win_close(existing_float, true) + end + + local floating_bufnr = api.nvim_create_buf(false, true) + local do_stylize = syntax == "markdown" and opts.stylize_markdown + -- Clean up input: trim empty lines from the end, pad contents = M._trim(contents, opts) + if do_stylize then + -- applies the syntax and sets the lines to the buffer + contents = M.stylize_markdown(floating_bufnr, contents, opts) + else + if syntax then + api.nvim_buf_set_option(floating_bufnr, 'syntax', syntax) + end + api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents) + end + -- Compute size of float needed to show (wrapped) lines - opts.wrap_at = opts.wrap_at or (vim.wo["wrap"] and api.nvim_win_get_width(0)) + if opts.wrap then + opts.wrap_at = opts.wrap_at or api.nvim_win_get_width(0) + else + opts.wrap_at = nil + end local width, height = M._make_floating_popup_size(contents, opts) - local floating_bufnr = api.nvim_create_buf(false, true) - if syntax then - api.nvim_buf_set_option(floating_bufnr, 'syntax', syntax) - end local float_option = M.make_floating_popup_options(width, height, opts) local floating_winnr = api.nvim_open_win(floating_bufnr, false, float_option) - if syntax == 'markdown' then + if do_stylize then api.nvim_win_set_option(floating_winnr, 'conceallevel', 2) + api.nvim_win_set_option(floating_winnr, 'concealcursor', 'n') end - api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents) + -- disable folding + api.nvim_win_set_option(floating_winnr, 'foldenable', false) + -- soft wrapping + api.nvim_win_set_option(floating_winnr, 'wrap', opts.wrap) + api.nvim_buf_set_option(floating_bufnr, 'modifiable', false) api.nvim_buf_set_option(floating_bufnr, 'bufhidden', 'wipe') - M.close_preview_autocmd({"CursorMoved", "CursorMovedI", "BufHidden", "BufLeave"}, floating_winnr) + api.nvim_buf_set_keymap(floating_bufnr, "n", "q", "<cmd>bdelete<cr>", {silent = true, noremap = true}) + M.close_preview_autocmd(opts.close_events, floating_winnr) + + -- save focus_id + if opts.focus_id then + api.nvim_win_set_var(floating_winnr, opts.focus_id, bufnr) + end + api.nvim_buf_set_var(bufnr, "lsp_floating_preview", floating_winnr) + return floating_bufnr, floating_winnr end @@ -1363,6 +1513,88 @@ local position_sort = sort_by_key(function(v) return {v.start.line, v.start.character} end) +-- Gets the zero-indexed line from the given uri. +-- For non-file uris, we load the buffer and get the line. +-- If a loaded buffer exists, then that is used. +-- Otherwise we get the line using libuv which is a lot faster than loading the buffer. +--@param uri string uri of the resource to get the line from +--@param row number zero-indexed line number +--@return string the line at row in filename +function M.get_line(uri, row) + return M.get_lines(uri, { row })[row] +end + +-- Gets the zero-indexed lines from the given uri. +-- For non-file uris, we load the buffer and get the lines. +-- If a loaded buffer exists, then that is used. +-- Otherwise we get the lines using libuv which is a lot faster than loading the buffer. +--@param uri string uri of the resource to get the lines from +--@param rows number[] zero-indexed line numbers +--@return table<number string> a table mapping rows to lines +function M.get_lines(uri, rows) + rows = type(rows) == "table" and rows or { rows } + + local function buf_lines(bufnr) + local lines = {} + for _, row in pairs(rows) do + lines[row] = (vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false) or { "" })[1] + end + return lines + end + + -- load the buffer if this is not a file uri + -- Custom language server protocol extensions can result in servers sending URIs with custom schemes. Plugins are able to load these via `BufReadCmd` autocmds. + if uri:sub(1, 4) ~= "file" then + local bufnr = vim.uri_to_bufnr(uri) + vim.fn.bufload(bufnr) + return buf_lines(bufnr) + end + + local filename = vim.uri_to_fname(uri) + + -- use loaded buffers if available + if vim.fn.bufloaded(filename) == 1 then + local bufnr = vim.fn.bufnr(filename, false) + return buf_lines(bufnr) + end + + -- get the data from the file + local fd = uv.fs_open(filename, "r", 438) + if not fd then return "" end + local stat = uv.fs_fstat(fd) + local data = uv.fs_read(fd, stat.size, 0) + uv.fs_close(fd) + + local lines = {} -- rows we need to retrieve + local need = 0 -- keep track of how many unique rows we need + for _, row in pairs(rows) do + if not lines[row] then + need = need + 1 + end + lines[row] = true + end + + local found = 0 + local lnum = 0 + + for line in string.gmatch(data, "([^\n]*)\n?") do + if lines[lnum] == true then + lines[lnum] = line + found = found + 1 + if found == need then break end + end + lnum = lnum + 1 + end + + -- change any lines we didn't find to the empty string + for i, line in pairs(lines) do + if line == true then + lines[i] = "" + end + end + return lines +end + --- Returns the items with the byte position calculated correctly and in sorted --- order, for display in quickfix and location lists. --- @@ -1391,14 +1623,24 @@ function M.locations_to_items(locations) for _, uri in ipairs(keys) do local rows = grouped[uri] table.sort(rows, position_sort) - local bufnr = vim.uri_to_bufnr(uri) - vim.fn.bufload(bufnr) local filename = vim.uri_to_fname(uri) + + -- list of row numbers + local uri_rows = {} for _, temp in ipairs(rows) do local pos = temp.start local row = pos.line - local line = (api.nvim_buf_get_lines(bufnr, row, row + 1, false) or {""})[1] - local col = M.character_offset(bufnr, row, pos.character) + table.insert(uri_rows, row) + end + + -- get all the lines for this uri + local lines = M.get_lines(uri, uri_rows) + + for _, temp in ipairs(rows) do + local pos = temp.start + local row = pos.line + local line = lines[row] or "" + local col = pos.character table.insert(items, { filename = filename, lnum = row + 1, @@ -1410,12 +1652,13 @@ function M.locations_to_items(locations) return items end ---- Fills current window's location list with given list of items. +--- Fills target window's location list with given list of items. --- Can be obtained with e.g. |vim.lsp.util.locations_to_items()|. +--- Defaults to current window. --- --@param items (table) list of items -function M.set_loclist(items) - vim.fn.setloclist(0, {}, ' ', { +function M.set_loclist(items, win_id) + vim.fn.setloclist(win_id or 0, {}, ' ', { title = 'Language Server'; items = items; }) @@ -1456,13 +1699,13 @@ function M.symbols_to_items(symbols, bufnr) kind = kind, text = '['..kind..'] '..symbol.name, }) - elseif symbol.range then -- DocumentSymbole type + elseif symbol.selectionRange then -- DocumentSymbole type local kind = M._get_symbol_kind_name(symbol.kind) table.insert(_items, { -- bufnr = _bufnr, filename = vim.api.nvim_buf_get_name(_bufnr), - lnum = symbol.range.start.line + 1, - col = symbol.range.start.character + 1, + lnum = symbol.selectionRange.start.line + 1, + col = symbol.selectionRange.start.character + 1, kind = kind, text = '['..kind..'] '..symbol.name }) @@ -1592,6 +1835,12 @@ function M.make_given_range_params(start_pos, end_pos) if B[2] > 0 then B = {B[1], M.character_offset(0, B[1], B[2])} end + -- we need to offset the end character position otherwise we loose the last + -- character of the selection, as LSP end position is exclusive + -- see https://microsoft.github.io/language-server-protocol/specification#range + if vim.o.selection ~= 'exclusive' then + B[2] = B[2] + 1 + end return { textDocument = M.make_text_document_params(), range = { @@ -1650,8 +1899,9 @@ end --@param row 0-indexed line --@param col 0-indexed byte offset in line --@returns (number, number) UTF-32 and UTF-16 index of the character in line {row} column {col} in buffer {buf} -function M.character_offset(buf, row, col) - local line = api.nvim_buf_get_lines(buf, row, row+1, true)[1] +function M.character_offset(bufnr, row, col) + local uri = vim.uri_from_bufnr(bufnr) + local line = M.get_line(uri, row) -- If the col is past the EOL, use the line length. if col > #line then return str_utfindex(line) @@ -1674,6 +1924,40 @@ function M.lookup_section(settings, section) return settings end + +--- Convert diagnostics grouped by bufnr to a list of items for use in the +--- quickfix or location list. +--- +--@param diagnostics_by_bufnr table bufnr -> Diagnostic[] +--@param predicate an optional function to filter the diagnostics. +-- If present, only diagnostic items matching will be included. +--@return table (A list of items) +function M.diagnostics_to_items(diagnostics_by_bufnr, predicate) + local items = {} + for bufnr, diagnostics in pairs(diagnostics_by_bufnr or {}) do + for _, d in pairs(diagnostics) do + if not predicate or predicate(d) then + table.insert(items, { + bufnr = bufnr, + lnum = d.range.start.line + 1, + col = d.range.start.character + 1, + text = d.message, + type = loclist_type_map[d.severity or DiagnosticSeverity.Error] or 'E' + }) + end + end + end + table.sort(items, function(a, b) + if a.bufnr == b.bufnr then + return a.lnum < b.lnum + else + return a.bufnr < b.bufnr + end + end) + return items +end + + M._get_line_byte_from_position = get_line_byte_from_position M._warn_once = warn_once diff --git a/runtime/lua/vim/treesitter.lua b/runtime/lua/vim/treesitter.lua index f223c7b8c8..de997b2d86 100644 --- a/runtime/lua/vim/treesitter.lua +++ b/runtime/lua/vim/treesitter.lua @@ -25,12 +25,12 @@ setmetatable(M, { }) --- Creates a new parser. --- --- It is not recommended to use this, use vim.treesitter.get_parser() instead. --- --- @param bufnr The buffer the parser will be tied to --- @param lang The language of the parser --- @param opts Options to pass to the language tree +--- +--- It is not recommended to use this, use vim.treesitter.get_parser() instead. +--- +--- @param bufnr The buffer the parser will be tied to +--- @param lang The language of the parser +--- @param opts Options to pass to the created language tree function M._create_parser(bufnr, lang, opts) language.require_language(lang) if bufnr == 0 then @@ -41,10 +41,12 @@ function M._create_parser(bufnr, lang, opts) local self = LanguageTree.new(bufnr, lang, opts) + ---@private local function bytes_cb(_, ...) self:_on_bytes(...) end + ---@private local function detach_cb(_, ...) if parsers[bufnr] == self then parsers[bufnr] = nil @@ -52,6 +54,7 @@ function M._create_parser(bufnr, lang, opts) self:_on_detach(...) end + ---@private local function reload_cb(_, ...) self:_on_reload(...) end @@ -64,15 +67,15 @@ function M._create_parser(bufnr, lang, opts) end --- Gets the parser for this bufnr / ft combination. --- --- If needed this will create the parser. --- Unconditionnally attach the provided callback --- --- @param bufnr The buffer the parser should be tied to --- @param ft The filetype of this parser --- @param opts Options object to pass to the parser --- --- @returns The parser +--- +--- If needed this will create the parser. +--- Unconditionnally attach the provided callback +--- +--- @param bufnr The buffer the parser should be tied to +--- @param lang The filetype of this parser +--- @param opts Options object to pass to the created language tree +--- +--- @returns The parser function M.get_parser(bufnr, lang, opts) opts = opts or {} @@ -92,6 +95,11 @@ function M.get_parser(bufnr, lang, opts) return parsers[bufnr] end +--- Gets a string parser +--- +--- @param str The string to parse +--- @param lang The language of this string +--- @param opts Options to pass to the created language tree function M.get_string_parser(str, lang, opts) vim.validate { str = { str, 'string' }, diff --git a/runtime/lua/vim/treesitter/health.lua b/runtime/lua/vim/treesitter/health.lua index dd0b11a6c7..e031ba1bd6 100644 --- a/runtime/lua/vim/treesitter/health.lua +++ b/runtime/lua/vim/treesitter/health.lua @@ -1,10 +1,14 @@ local M = {} local ts = vim.treesitter +--- Lists the parsers currently installed +--- +---@return A list of parsers function M.list_parsers() return vim.api.nvim_get_runtime_file('parser/*', true) end +--- Performs a healthcheck for treesitter integration function M.check_health() local report_info = vim.fn['health#report_info'] local report_ok = vim.fn['health#report_ok'] diff --git a/runtime/lua/vim/treesitter/highlighter.lua b/runtime/lua/vim/treesitter/highlighter.lua index fe7e1052c9..84b6a5f135 100644 --- a/runtime/lua/vim/treesitter/highlighter.lua +++ b/runtime/lua/vim/treesitter/highlighter.lua @@ -70,11 +70,13 @@ TSHighlighter.hl_map = { ["include"] = "Include", } +---@private local function is_highlight_name(capture_name) local firstc = string.sub(capture_name, 1, 1) return firstc ~= string.lower(firstc) end +---@private function TSHighlighterQuery.new(lang, query_string) local self = setmetatable({}, { __index = TSHighlighterQuery }) @@ -99,10 +101,12 @@ function TSHighlighterQuery.new(lang, query_string) return self end +---@private function TSHighlighterQuery:query() return self._query end +---@private --- Get the hl from capture. --- Returns a tuple { highlight_name: string, is_builtin: bool } function TSHighlighterQuery:_get_hl_from_capture(capture) @@ -116,6 +120,11 @@ function TSHighlighterQuery:_get_hl_from_capture(capture) end end +--- Creates a new highlighter using @param tree +--- +--- @param tree The language tree to use for highlighting +--- @param opts Table used to configure the highlighter +--- - queries: Table to overwrite queries used by the highlighter function TSHighlighter.new(tree, opts) local self = setmetatable({}, TSHighlighter) @@ -165,12 +174,14 @@ function TSHighlighter.new(tree, opts) return self end +--- Removes all internal references to the highlighter function TSHighlighter:destroy() if TSHighlighter.active[self.bufnr] then TSHighlighter.active[self.bufnr] = nil end end +---@private function TSHighlighter:get_highlight_state(tstree) if not self._highlight_states[tstree] then self._highlight_states[tstree] = { @@ -182,24 +193,31 @@ function TSHighlighter:get_highlight_state(tstree) return self._highlight_states[tstree] end +---@private function TSHighlighter:reset_highlight_state() self._highlight_states = {} end +---@private function TSHighlighter:on_bytes(_, _, start_row, _, _, _, _, _, new_end) a.nvim__buf_redraw_range(self.bufnr, start_row, start_row + new_end + 1) end +---@private function TSHighlighter:on_detach() self:destroy() end +---@private function TSHighlighter:on_changedtree(changes) for _, ch in ipairs(changes or {}) do a.nvim__buf_redraw_range(self.bufnr, ch[1], ch[3]+1) end end +--- Gets the query used for @param lang +--- +--- @param lang A language used by the highlighter. function TSHighlighter:get_query(lang) if not self._queries[lang] then self._queries[lang] = TSHighlighterQuery.new(lang) @@ -208,6 +226,7 @@ function TSHighlighter:get_query(lang) return self._queries[lang] end +---@private local function on_line_impl(self, buf, line) self.tree:for_each_tree(function(tstree, tree) if not tstree then return end @@ -251,6 +270,7 @@ local function on_line_impl(self, buf, line) end, true) end +---@private function TSHighlighter._on_line(_, _win, buf, line, _) local self = TSHighlighter.active[buf] if not self then return end @@ -258,6 +278,7 @@ function TSHighlighter._on_line(_, _win, buf, line, _) on_line_impl(self, buf, line) end +---@private function TSHighlighter._on_buf(_, buf) local self = TSHighlighter.active[buf] if self then @@ -265,6 +286,7 @@ function TSHighlighter._on_buf(_, buf) end end +---@private function TSHighlighter._on_win(_, _win, buf, _topline) local self = TSHighlighter.active[buf] if not self then diff --git a/runtime/lua/vim/treesitter/language.lua b/runtime/lua/vim/treesitter/language.lua index eed28e0e41..6dc37c7848 100644 --- a/runtime/lua/vim/treesitter/language.lua +++ b/runtime/lua/vim/treesitter/language.lua @@ -3,12 +3,12 @@ local a = vim.api local M = {} --- Asserts that the provided language is installed, and optionally provide a path for the parser --- --- Parsers are searched in the `parser` runtime directory. --- --- @param lang The language the parser should parse --- @param path Optional path the parser is located at --- @param silent Don't throw an error if language not found +--- +--- Parsers are searched in the `parser` runtime directory. +--- +--- @param lang The language the parser should parse +--- @param path Optional path the parser is located at +--- @param silent Don't throw an error if language not found function M.require_language(lang, path, silent) if vim._ts_has_language(lang) then return true @@ -37,10 +37,10 @@ function M.require_language(lang, path, silent) end --- Inspects the provided language. --- --- Inspecting provides some useful informations on the language like node names, ... --- --- @param lang The language. +--- +--- Inspecting provides some useful informations on the language like node names, ... +--- +--- @param lang The language. function M.inspect_language(lang) M.require_language(lang) return vim._ts_inspect_language(lang) diff --git a/runtime/lua/vim/treesitter/languagetree.lua b/runtime/lua/vim/treesitter/languagetree.lua index 2f5aeb0710..899d90e464 100644 --- a/runtime/lua/vim/treesitter/languagetree.lua +++ b/runtime/lua/vim/treesitter/languagetree.lua @@ -5,16 +5,16 @@ local language = require'vim.treesitter.language' local LanguageTree = {} LanguageTree.__index = LanguageTree --- Represents a single treesitter parser for a language. --- The language can contain child languages with in its range, --- hence the tree. --- --- @param source Can be a bufnr or a string of text to parse --- @param lang The language this tree represents --- @param opts Options table --- @param opts.injections A table of language to injection query strings. --- This is useful for overriding the built-in runtime file --- searching for the injection language query per language. +--- Represents a single treesitter parser for a language. +--- The language can contain child languages with in its range, +--- hence the tree. +--- +--- @param source Can be a bufnr or a string of text to parse +--- @param lang The language this tree represents +--- @param opts Options table +--- @param opts.injections A table of language to injection query strings. +--- This is useful for overriding the built-in runtime file +--- searching for the injection language query per language. function LanguageTree.new(source, lang, opts) language.require_language(lang) opts = opts or {} @@ -50,7 +50,7 @@ function LanguageTree.new(source, lang, opts) return self end --- Invalidates this parser and all its children +--- Invalidates this parser and all its children function LanguageTree:invalidate(reload) self._valid = false @@ -64,38 +64,38 @@ function LanguageTree:invalidate(reload) end end --- Returns all trees this language tree contains. --- Does not include child languages. +--- Returns all trees this language tree contains. +--- Does not include child languages. function LanguageTree:trees() return self._trees end --- Gets the language of this tree layer. +--- Gets the language of this tree node. function LanguageTree:lang() return self._lang end --- Determines whether this tree is valid. --- If the tree is invalid, `parse()` must be called --- to get the an updated tree. +--- Determines whether this tree is valid. +--- If the tree is invalid, `parse()` must be called +--- to get the an updated tree. function LanguageTree:is_valid() return self._valid end --- Returns a map of language to child tree. +--- Returns a map of language to child tree. function LanguageTree:children() return self._children end --- Returns the source content of the language tree (bufnr or string). +--- Returns the source content of the language tree (bufnr or string). function LanguageTree:source() return self._source end --- Parses all defined regions using a treesitter parser --- for the language this tree represents. --- This will run the injection query for this language to --- determine if any child languages should be created. +--- Parses all defined regions using a treesitter parser +--- for the language this tree represents. +--- This will run the injection query for this language to +--- determine if any child languages should be created. function LanguageTree:parse() if self._valid then return self._trees @@ -169,9 +169,10 @@ function LanguageTree:parse() return self._trees, changes end --- Invokes the callback for each LanguageTree and it's children recursively --- @param fn The function to invoke. This is invoked with arguments (tree: LanguageTree, lang: string) --- @param include_self Whether to include the invoking tree in the results. +--- Invokes the callback for each LanguageTree and it's children recursively +--- +--- @param fn The function to invoke. This is invoked with arguments (tree: LanguageTree, lang: string) +--- @param include_self Whether to include the invoking tree in the results. function LanguageTree:for_each_child(fn, include_self) if include_self then fn(self, self._lang) @@ -182,10 +183,12 @@ function LanguageTree:for_each_child(fn, include_self) end end --- Invokes the callback for each treesitter trees recursively. --- Note, this includes the invoking language tree's trees as well. --- @param fn The callback to invoke. The callback is invoked with arguments --- (tree: TSTree, languageTree: LanguageTree) +--- Invokes the callback for each treesitter trees recursively. +--- +--- Note, this includes the invoking language tree's trees as well. +--- +--- @param fn The callback to invoke. The callback is invoked with arguments +--- (tree: TSTree, languageTree: LanguageTree) function LanguageTree:for_each_tree(fn) for _, tree in ipairs(self._trees) do fn(tree, self) @@ -196,9 +199,11 @@ function LanguageTree:for_each_tree(fn) end end --- Adds a child language to this tree. --- If the language already exists as a child, it will first be removed. --- @param lang The language to add. +--- Adds a child language to this tree. +--- +--- If the language already exists as a child, it will first be removed. +--- +--- @param lang The language to add. function LanguageTree:add_child(lang) if self._children[lang] then self:remove_child(lang) @@ -212,8 +217,9 @@ function LanguageTree:add_child(lang) return self._children[lang] end --- Removes a child language from this tree. --- @param lang The language to remove. +--- Removes a child language from this tree. +--- +--- @param lang The language to remove. function LanguageTree:remove_child(lang) local child = self._children[lang] @@ -225,10 +231,11 @@ function LanguageTree:remove_child(lang) end end --- Destroys this language tree and all its children. --- Any cleanup logic should be performed here. --- Note, this DOES NOT remove this tree from a parent. --- `remove_child` must be called on the parent to remove it. +--- Destroys this language tree and all its children. +--- +--- Any cleanup logic should be performed here. +--- Note, this DOES NOT remove this tree from a parent. +--- `remove_child` must be called on the parent to remove it. function LanguageTree:destroy() -- Cleanup here for _, child in ipairs(self._children) do @@ -236,23 +243,23 @@ function LanguageTree:destroy() end end --- Sets the included regions that should be parsed by this parser. --- A region is a set of nodes and/or ranges that will be parsed in the same context. --- --- For example, `{ { node1 }, { node2} }` is two separate regions. --- This will be parsed by the parser in two different contexts... thus resulting --- in two separate trees. --- --- `{ { node1, node2 } }` is a single region consisting of two nodes. --- This will be parsed by the parser in a single context... thus resulting --- in a single tree. --- --- This allows for embedded languages to be parsed together across different --- nodes, which is useful for templating languages like ERB and EJS. --- --- Note, this call invalidates the tree and requires it to be parsed again. --- --- @param regions A list of regions this tree should manage and parse. +--- Sets the included regions that should be parsed by this parser. +--- A region is a set of nodes and/or ranges that will be parsed in the same context. +--- +--- For example, `{ { node1 }, { node2} }` is two separate regions. +--- This will be parsed by the parser in two different contexts... thus resulting +--- in two separate trees. +--- +--- `{ { node1, node2 } }` is a single region consisting of two nodes. +--- This will be parsed by the parser in a single context... thus resulting +--- in a single tree. +--- +--- This allows for embedded languages to be parsed together across different +--- nodes, which is useful for templating languages like ERB and EJS. +--- +--- Note, this call invalidates the tree and requires it to be parsed again. +--- +--- @param regions A list of regions this tree should manage and parse. function LanguageTree:set_included_regions(regions) -- TODO(vigoux): I don't think string parsers are useful for now if type(self._source) == "number" then @@ -281,16 +288,18 @@ function LanguageTree:set_included_regions(regions) self:invalidate() end --- Gets the set of included regions +--- Gets the set of included regions function LanguageTree:included_regions() return self._regions end --- Gets language injection points by language. --- This is where most of the injection processing occurs. --- TODO: Allow for an offset predicate to tailor the injection range --- instead of using the entire nodes range. --- @private +--- Gets language injection points by language. +--- +--- This is where most of the injection processing occurs. +--- +--- TODO: Allow for an offset predicate to tailor the injection range +--- instead of using the entire nodes range. +--- @private function LanguageTree:_get_injections() if not self._injection_query then return {} end @@ -395,12 +404,14 @@ function LanguageTree:_get_injections() return result end +---@private function LanguageTree:_do_callback(cb_name, ...) for _, cb in ipairs(self._callbacks[cb_name]) do cb(...) end end +---@private function LanguageTree:_on_bytes(bufnr, changed_tick, start_row, start_col, start_byte, old_row, old_col, old_byte, @@ -425,24 +436,26 @@ function LanguageTree:_on_bytes(bufnr, changed_tick, new_row, new_col, new_byte) end +---@private function LanguageTree:_on_reload() self:invalidate(true) end +---@private function LanguageTree:_on_detach(...) self:invalidate(true) self:_do_callback('detach', ...) end --- Registers callbacks for the parser --- @param cbs An `nvim_buf_attach`-like table argument with the following keys : --- `on_bytes` : see `nvim_buf_attach`, but this will be called _after_ the parsers callback. --- `on_changedtree` : a callback that will be called every time the tree has syntactical changes. --- it will only be passed one argument, that is a table of the ranges (as node ranges) that --- changed. --- `on_child_added` : emitted when a child is added to the tree. --- `on_child_removed` : emitted when a child is removed from the tree. +--- @param cbs An `nvim_buf_attach`-like table argument with the following keys : +--- `on_bytes` : see `nvim_buf_attach`, but this will be called _after_ the parsers callback. +--- `on_changedtree` : a callback that will be called every time the tree has syntactical changes. +--- it will only be passed one argument, that is a table of the ranges (as node ranges) that +--- changed. +--- `on_child_added` : emitted when a child is added to the tree. +--- `on_child_removed` : emitted when a child is removed from the tree. function LanguageTree:register_cbs(cbs) if not cbs then return end @@ -467,6 +480,7 @@ function LanguageTree:register_cbs(cbs) end end +---@private local function tree_contains(tree, range) local start_row, start_col, end_row, end_col = tree:root():range() local start_fits = start_row < range[1] or (start_row == range[1] and start_col <= range[2]) @@ -479,6 +493,11 @@ local function tree_contains(tree, range) return false end +--- Determines wether @param range is contained in this language tree +--- +--- This goes down the tree to recursively check childs. +--- +--- @param range A range, that is a `{ start_line, start_col, end_line, end_col }` table. function LanguageTree:contains(range) for _, tree in pairs(self._trees) do if tree_contains(tree, range) then @@ -489,6 +508,9 @@ function LanguageTree:contains(range) return false end +--- Gets the appropriate language that contains @param range +--- +--- @param range A text range, see |LanguageTree:contains| function LanguageTree:language_for_range(range) for _, child in pairs(self._children) do if child:contains(range) then diff --git a/runtime/lua/vim/treesitter/query.lua b/runtime/lua/vim/treesitter/query.lua index ed5146be44..b81eb18945 100644 --- a/runtime/lua/vim/treesitter/query.lua +++ b/runtime/lua/vim/treesitter/query.lua @@ -8,6 +8,7 @@ Query.__index = Query local M = {} +---@private local function dedupe_files(files) local result = {} local seen = {} @@ -22,6 +23,7 @@ local function dedupe_files(files) return result end +---@private local function safe_read(filename, read_quantifier) local file, err = io.open(filename, 'r') if not file then @@ -32,6 +34,11 @@ local function safe_read(filename, read_quantifier) return content end +--- Gets the list of files used to make up a query +--- +--- @param lang The language +--- @param query_name The name of the query to load +--- @param is_included Internal parameter, most of the time left as `nil` function M.get_query_files(lang, query_name, is_included) local query_path = string.format('queries/%s/%s.scm', lang, query_name) local lang_files = dedupe_files(a.nvim_get_runtime_file(query_path, true)) @@ -79,6 +86,7 @@ function M.get_query_files(lang, query_name, is_included) return query_files end +---@private local function read_query_files(filenames) local contents = {} @@ -103,19 +111,20 @@ local explicit_queries = setmetatable({}, { --- --- This allows users to override any runtime files and/or configuration --- set by plugins. ----@param lang string: The language to use for the query ----@param query_name string: The name of the query (i.e. "highlights") ----@param text string: The query text (unparsed). +--- +--- @param lang string: The language to use for the query +--- @param query_name string: The name of the query (i.e. "highlights") +--- @param text string: The query text (unparsed). function M.set_query(lang, query_name, text) explicit_queries[lang][query_name] = M.parse_query(lang, text) end --- Returns the runtime query {query_name} for {lang}. --- --- @param lang The language to use for the query --- @param query_name The name of the query (i.e. "highlights") --- --- @return The corresponding query, parsed. +--- +--- @param lang The language to use for the query +--- @param query_name The name of the query (i.e. "highlights") +--- +--- @return The corresponding query, parsed. function M.get_query(lang, query_name) if explicit_queries[lang][query_name] then return explicit_queries[lang][query_name] @@ -129,12 +138,23 @@ function M.get_query(lang, query_name) end end ---- Parses a query. --- --- @param language The language --- @param query A string containing the query (s-expr syntax) --- --- @returns The query +--- Parse {query} as a string. (If the query is in a file, the caller +--- should read the contents into a string before calling). +--- +--- Returns a `Query` (see |lua-treesitter-query|) object which can be used to +--- search nodes in the syntax tree for the patterns defined in {query} +--- using `iter_*` methods below. +--- +--- Exposes `info` and `captures` with additional information about the {query}. +--- - `captures` contains the list of unique capture names defined in +--- {query}. +--- -` info.captures` also points to `captures`. +--- - `info.patterns` contains information about predicates. +--- +--- @param lang The language +--- @param query A string containing the query (s-expr syntax) +--- +--- @returns The query function M.parse_query(lang, query) language.require_language(lang) local self = setmetatable({}, Query) @@ -147,8 +167,9 @@ end -- TODO(vigoux): support multiline nodes too --- Gets the text corresponding to a given node --- @param node the node --- @param bufnr the buffer from which the node is extracted. +--- +--- @param node the node +--- @param bsource The buffer or string from which the node is extracted function M.get_node_text(node, source) local start_row, start_col, start_byte = node:start() local end_row, end_col, end_byte = node:end_() @@ -200,6 +221,7 @@ local predicate_handlers = { ["match?"] = (function() local magic_prefixes = {['\\v']=true, ['\\m']=true, ['\\M']=true, ['\\V']=true} + ---@private local function check_magic(str) if string.len(str) < 2 or magic_prefixes[string.sub(str,1,2)] then return str @@ -209,7 +231,7 @@ local predicate_handlers = { local compiled_vim_regexes = setmetatable({}, { __index = function(t, pattern) - local res = vim.regex(check_magic(vim.fn.escape(pattern, '\\'))) + local res = vim.regex(check_magic(pattern)) rawset(t, pattern, res) return res end @@ -238,7 +260,25 @@ local predicate_handlers = { end return false - end + end, + + ["any-of?"] = function(match, _, source, predicate) + local node = match[predicate[2]] + local node_text = M.get_node_text(node, source) + + -- Since 'predicate' will not be used by callers of this function, use it + -- to store a string set built from the list of words to check against. + local string_set = predicate["string_set"] + if not string_set then + string_set = {} + for i=3,#predicate do + string_set[predicate[i]] = true + end + predicate["string_set"] = string_set + end + + return string_set[node_text] + end, } -- As we provide lua-match? also expose vim-match? @@ -253,7 +293,11 @@ local directive_handlers = { ["set!"] = function(_, _, _, pred, metadata) if #pred == 4 then -- (#set! @capture "key" "value") - metadata[pred[2]][pred[3]] = pred[4] + local capture = pred[2] + if not metadata[capture] then + metadata[capture] = {} + end + metadata[capture][pred[3]] = pred[4] else -- (#set! "key" "value") metadata[pred[2]] = pred[3] @@ -282,10 +326,10 @@ local directive_handlers = { } --- Adds a new predicate to be used in queries --- --- @param name the name of the predicate, without leading # --- @param handler the handler function to be used --- signature will be (match, pattern, bufnr, predicate) +--- +--- @param name the name of the predicate, without leading # +--- @param handler the handler function to be used +--- signature will be (match, pattern, bufnr, predicate) function M.add_predicate(name, handler, force) if predicate_handlers[name] and not force then error(string.format("Overriding %s", name)) @@ -295,10 +339,10 @@ function M.add_predicate(name, handler, force) end --- Adds a new directive to be used in queries --- --- @param name the name of the directive, without leading # --- @param handler the handler function to be used --- signature will be (match, pattern, bufnr, predicate) +--- +--- @param name the name of the directive, without leading # +--- @param handler the handler function to be used +--- signature will be (match, pattern, bufnr, predicate) function M.add_directive(name, handler, force) if directive_handlers[name] and not force then error(string.format("Overriding %s", name)) @@ -312,14 +356,17 @@ function M.list_predicates() return vim.tbl_keys(predicate_handlers) end +---@private local function xor(x, y) return (x or y) and not (x and y) end +---@private local function is_directive(name) return string.sub(name, -1) == "!" end +---@private function Query:match_preds(match, pattern, source) local preds = self.info.patterns[pattern] @@ -358,7 +405,7 @@ function Query:match_preds(match, pattern, source) return true end ---- Applies directives against a match and pattern. +---@private function Query:apply_directives(match, pattern, source, metadata) local preds = self.info.patterns[pattern] @@ -380,6 +427,7 @@ end --- Returns the start and stop value if set else the node's range. -- When the node's range is used, the stop is incremented by 1 -- to make the search inclusive. +---@private local function value_or_node_range(start, stop, node) if start == nil and stop == nil then local node_start, _, node_stop, _ = node:range() @@ -389,15 +437,36 @@ local function value_or_node_range(start, stop, node) return start, stop end ---- Iterates of the captures of self on a given range. --- --- @param node The node under which the search will occur --- @param buffer The source buffer to search --- @param start The starting line of the search --- @param stop The stopping line of the search (end-exclusive) --- --- @returns The matching capture id --- @returns The captured node +--- Iterate over all captures from all matches inside {node} +--- +--- {source} is needed if the query contains predicates, then the caller +--- must ensure to use a freshly parsed tree consistent with the current +--- text of the buffer (if relevent). {start_row} and {end_row} can be used to limit +--- matches inside a row range (this is typically used with root node +--- as the node, i e to get syntax highlight matches in the current +--- viewport). When omitted the start and end row values are used from the given node. +--- +--- The iterator returns three values, a numeric id identifying the capture, +--- the captured node, and metadata from any directives processing the match. +--- The following example shows how to get captures by name: +--- +--- <pre> +--- for id, node, metadata in query:iter_captures(tree:root(), bufnr, first, last) do +--- local name = query.captures[id] -- name of the capture in the query +--- -- typically useful info about the node: +--- local type = node:type() -- type of the captured node +--- local row1, col1, row2, col2 = node:range() -- range of the capture +--- ... use the info here ... +--- end +--- </pre> +--- +--- @param node The node under which the search will occur +--- @param source The source buffer or string to exctract text from +--- @param start The starting line of the search +--- @param stop The stopping line of the search (end-exclusive) +--- +--- @returns The matching capture id +--- @returns The captured node function Query:iter_captures(node, source, start, stop) if type(source) == "number" and source == 0 then source = vim.api.nvim_get_current_buf() @@ -406,6 +475,7 @@ function Query:iter_captures(node, source, start, stop) start, stop = value_or_node_range(start, stop, node) local raw_iter = node:_rawquery(self.query, true, start, stop) + ---@private local function iter() local capture, captured_node, match = raw_iter() local metadata = {} @@ -425,14 +495,35 @@ function Query:iter_captures(node, source, start, stop) end --- Iterates the matches of self on a given range. --- --- @param node The node under which the search will occur --- @param buffer The source buffer to search --- @param start The starting line of the search --- @param stop The stopping line of the search (end-exclusive) --- --- @returns The matching pattern id --- @returns The matching match +--- +--- Iterate over all matches within a node. The arguments are the same as +--- for |query:iter_captures()| but the iterated values are different: +--- an (1-based) index of the pattern in the query, a table mapping +--- capture indices to nodes, and metadata from any directives processing the match. +--- If the query has more than one pattern the capture table might be sparse, +--- and e.g. `pairs()` method should be used over `ipairs`. +--- Here an example iterating over all captures in every match: +--- +--- <pre> +--- for pattern, match, metadata in cquery:iter_matches(tree:root(), bufnr, first, last) do +--- for id, node in pairs(match) do +--- local name = query.captures[id] +--- -- `node` was captured by the `name` capture in the match +--- +--- local node_data = metadata[id] -- Node level metadata +--- +--- ... use the info here ... +--- end +--- end +--- </pre> +--- +--- @param node The node under which the search will occur +--- @param source The source buffer or string to search +--- @param start The starting line of the search +--- @param stop The stopping line of the search (end-exclusive) +--- +--- @returns The matching pattern id +--- @returns The matching match function Query:iter_matches(node, source, start, stop) if type(source) == "number" and source == 0 then source = vim.api.nvim_get_current_buf() |